import { useEffect, useMemo, useState } from 'react'; import Taro, { useRouter } from '@tarojs/taro'; import { Button, Text, Textarea, View } from '@tarojs/components'; import { loadCollectionQuestions, loadQuestions } from '@/services/catalog'; import { createPracticeSession, loadPracticeSessionDetail, type SubAnswerInput, type SubAnswerResult, submitAnswer, submitPracticeSession, toggleQuestionFavorite, type AnswerResult, type PracticeReport, type PracticeSession, type QuestionItem, } from '@/services/learning'; import { submitFeedback } from '@/services/profile'; import { getStorage, setStorage } from '@/services/storage'; import '../student.css'; type AnswerState = { selectedOptions: string[]; answerText: string; isCorrect: boolean | null; answeredAt?: string; selfJudged?: boolean; subAnswers?: SubAnswerInput[]; subResults?: SubAnswerResult[]; }; type LocalSubAnswer = { selectedOptions: string[]; answerText: string; selfJudgedCorrect?: boolean; }; function plainOption(option: unknown, index: number) { if (typeof option === 'string') return option; if (option && typeof option === 'object') { const data = option as Record; return String(data.text || data.content || data.label || data.value || `选项 ${index + 1}`); } return `选项 ${index + 1}`; } function isObjectiveQuestion(question?: QuestionItem | null) { if (!question) return false; return Array.isArray(question.options) && question.options.length > 0; } function isMultiQuestion(question?: QuestionItem | null) { if (!question) return false; const type = String(question.type || '').toLowerCase(); return type.includes('multi') || (question.correctOptionIndices?.length || 0) > 1; } function subQuestionId(subQuestion: Record, index: number) { const raw = subQuestion.id || subQuestion.subQuestionId || subQuestion.key; return typeof raw === 'string' && raw.trim() ? raw.trim() : `sub_${index + 1}`; } function subQuestionType(subQuestion: Record) { return String(subQuestion.type || 'choice').toLowerCase(); } function subQuestionOptions(subQuestion: Record) { return Array.isArray(subQuestion.options) ? subQuestion.options : []; } function isObjectiveSubQuestion(subQuestion: Record) { const type = subQuestionType(subQuestion); return ['choice', 'multi', 'judge', 'image'].includes(type) || subQuestionOptions(subQuestion).length > 0; } function isMultiSubQuestion(subQuestion: Record) { const type = subQuestionType(subQuestion); const correct = subQuestion.correctOptionIndices; return type.includes('multi') || (Array.isArray(correct) && correct.length > 1); } function answerStorageKey(sessionId: string) { return `tiku:practice:${sessionId}:answers`; } function indexStorageKey(sessionId: string) { return `tiku:practice:${sessionId}:index`; } function formatDuration(seconds: number) { const safeSeconds = Math.max(0, Math.trunc(seconds)); const minutes = Math.floor(safeSeconds / 60); const rest = safeSeconds % 60; return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`; } function answerLabel(state?: AnswerState) { if (!state) return '未答'; if (state.isCorrect === true) return '正确'; if (state.isCorrect === false) return '错误'; return state.selfJudged ? '已自评' : '已答'; } function answersFromBackend(answers?: Record): Record { const result: Record = {}; for (const [questionId, answer] of Object.entries(answers || {})) { result[questionId] = { selectedOptions: Array.isArray(answer.selectedOptions) ? answer.selectedOptions : [], answerText: answer.answerText || '', isCorrect: answer.isCorrect, answeredAt: answer.answeredAt, selfJudged: answer.selfJudged, subAnswers: Array.isArray(answer.answerPayload?.subAnswers) ? answer.answerPayload.subAnswers : [], subResults: Array.isArray(answer.answerPayload?.subResults) ? answer.answerPayload.subResults : answer.subResults || [], }; } return result; } function subAnswerStateFrom(answer?: AnswerState): Record { const result: Record = {}; for (const item of answer?.subAnswers || []) { result[item.subQuestionId] = { selectedOptions: Array.isArray(item.selectedOptions) ? item.selectedOptions : [], answerText: item.answerText || '', selfJudgedCorrect: item.selfJudgedCorrect, }; } if (!Object.keys(result).length) { for (const item of answer?.subResults || []) { result[item.subQuestionId] = { selectedOptions: Array.isArray(item.selectedOptions) ? item.selectedOptions : [], answerText: item.answerText || '', selfJudgedCorrect: item.selfJudged ? item.isCorrect === true : undefined, }; } } return result; } function secondsUntil(value?: string | null) { if (!value) return null; const parsed = new Date(value).getTime(); if (!Number.isFinite(parsed)) return null; return Math.max(0, Math.ceil((parsed - Date.now()) / 1000)); } export default function StudentPracticePage() { const router = useRouter(); const params = router.params || {}; const [session, setSession] = useState(null); const [questions, setQuestions] = useState([]); const [index, setIndex] = useState(0); const [selected, setSelected] = useState([]); const [answerText, setAnswerText] = useState(''); const [subAnswerById, setSubAnswerById] = useState>({}); const [feedbackText, setFeedbackText] = useState(''); const [answerByQuestion, setAnswerByQuestion] = useState>({}); const [favoriteByQuestion, setFavoriteByQuestion] = useState>({}); const [showExplanation, setShowExplanation] = useState(false); const [completed, setCompleted] = useState(false); const [timeLeft, setTimeLeft] = useState(null); const [loading, setLoading] = useState(true); const [report, setReport] = useState(null); const [error, setError] = useState(''); useEffect(() => { const practiceSessionId = params.practiceSessionId || undefined; const collectionId = params.collectionId || undefined; const body = { blueprintId: params.blueprintId || undefined, collectionId, entryId: params.entryId || undefined, contentNodeId: params.contentNodeId || undefined, mode: params.mode || 'sequential', questionLimit: 50, }; if (practiceSessionId) { loadPracticeSessionDetail(practiceSessionId) .then(payload => { const nextSession = payload.item; const backendAnswers = answersFromBackend(nextSession.answersByQuestion); setSession(nextSession); setQuestions(nextSession.questions || []); setAnswerByQuestion(backendAnswers); const savedIndex = getStorage(indexStorageKey(nextSession.id)); const firstUnanswered = (nextSession.questionIds || []).findIndex(questionId => !backendAnswers[questionId]); setIndex(typeof savedIndex === 'number' ? savedIndex : Math.max(0, firstUnanswered)); const remaining = secondsUntil(nextSession.expiresAt); if (remaining !== null) setTimeLeft(remaining); if (nextSession.status === 'finished') setCompleted(true); }) .catch(nextError => setError(nextError instanceof Error ? nextError.message : '练习恢复失败')) .finally(() => setLoading(false)); return; } createPracticeSession(body) .then(async payload => { const nextSession = payload.item; setSession(nextSession); if (nextSession.durationMinutes) setTimeLeft(nextSession.durationMinutes * 60); const savedIndex = getStorage(indexStorageKey(nextSession.id)); const savedAnswers = getStorage>(answerStorageKey(nextSession.id)); if (savedAnswers) setAnswerByQuestion(savedAnswers); if (typeof savedIndex === 'number') setIndex(savedIndex); const questionPayload = collectionId ? await loadCollectionQuestions(collectionId, 300) : await loadQuestions({ entryId: body.entryId, contentNodeId: body.contentNodeId, questionIds: nextSession.questionIds, limit: Math.max(nextSession.questionIds?.length || 0, 50), }); const byId = new Map((questionPayload.items || []).map(item => [item.id, item])); const ordered = (nextSession.questionIds || []).map(id => byId.get(id)).filter(Boolean) as QuestionItem[]; setQuestions(ordered.length ? ordered : questionPayload.items || []); }) .catch(nextError => setError(nextError instanceof Error ? nextError.message : '练习创建失败')) .finally(() => setLoading(false)); }, []); const current = questions[index]; const options = useMemo(() => Array.isArray(current?.options) ? current.options : [], [current]); const subQuestions = useMemo( () => (Array.isArray(current?.subQuestions) ? current.subQuestions : []) as Record[], [current], ); const hasCompositeSubQuestions = subQuestions.length > 0; const answerState = current ? answerByQuestion[current.id] : undefined; const isSubmitted = !!answerState; const answeredCount = Object.keys(answerByQuestion).length; const correctCount = Object.values(answerByQuestion).filter(item => item.isCorrect === true).length; const wrongCount = Object.values(answerByQuestion).filter(item => item.isCorrect === false).length; const progressPercent = questions.length ? Math.round((answeredCount / questions.length) * 100) : 0; useEffect(() => { if (!session) return; setStorage(indexStorageKey(session.id), index); }, [index, session?.id]); useEffect(() => { if (!session) return; setStorage(answerStorageKey(session.id), answerByQuestion); }, [answerByQuestion, session?.id]); useEffect(() => { if (!current) return; const state = answerByQuestion[current.id]; setSelected(state?.selectedOptions || []); setAnswerText(state?.answerText || ''); setSubAnswerById(subAnswerStateFrom(state)); setFeedbackText(''); setShowExplanation(!!state); }, [current?.id]); useEffect(() => { if (timeLeft === null || report || completed) return; if (timeLeft <= 0) { void handleSubmitSession(true); return; } const timer = setTimeout(() => setTimeLeft(prev => (prev === null ? null : Math.max(0, prev - 1))), 1000); return () => clearTimeout(timer); }, [timeLeft, report, completed]); function rememberAnswer( questionId: string, result: AnswerResult | { isCorrect: boolean | null; answeredAt?: string; selfJudged?: boolean }, nextSelected = selected, nextAnswerText = answerText, ) { const nextState: AnswerState = { selectedOptions: [...nextSelected], answerText: nextAnswerText, isCorrect: result.isCorrect, answeredAt: 'answeredAt' in result ? result.answeredAt : undefined, selfJudged: 'selfJudged' in result ? result.selfJudged : undefined, subAnswers: 'answerPayload' in result && Array.isArray(result.answerPayload?.subAnswers) ? result.answerPayload.subAnswers : undefined, subResults: 'subResults' in result && Array.isArray(result.subResults) ? result.subResults : 'answerPayload' in result && Array.isArray(result.answerPayload?.subResults) ? result.answerPayload.subResults : undefined, }; setAnswerByQuestion(prev => ({ ...prev, [questionId]: nextState })); setShowExplanation(true); } function toggleOption(optionIndex: number) { if (isSubmitted) return; const value = String(optionIndex); const nextSelected = selected.includes(value) ? selected.filter(item => item !== value) : [...selected, value]; setSelected(nextSelected); if (current && !isMultiQuestion(current) && nextSelected.length === 1) { void handleSubmit(nextSelected); } } function updateSubAnswer(subQuestionId: string, patch: Partial) { setSubAnswerById(prev => { const currentState = prev[subQuestionId] || { selectedOptions: [], answerText: '' }; return { ...prev, [subQuestionId]: { ...currentState, ...patch, }, }; }); } function toggleSubOption(subQuestion: Record, subQuestionIndex: number, optionIndex: number) { if (isSubmitted) return; const id = subQuestionId(subQuestion, subQuestionIndex); const value = String(optionIndex); const currentState = subAnswerById[id] || { selectedOptions: [], answerText: '' }; const nextSelected = currentState.selectedOptions.includes(value) ? currentState.selectedOptions.filter(item => item !== value) : isMultiSubQuestion(subQuestion) ? [...currentState.selectedOptions, value] : [value]; updateSubAnswer(id, { selectedOptions: nextSelected }); } function buildSubAnswers() { return subQuestions .map((subQuestion, subQuestionIndex) => { const id = subQuestionId(subQuestion, subQuestionIndex); const state = subAnswerById[id] || { selectedOptions: [], answerText: '' }; const objective = isObjectiveSubQuestion(subQuestion); return { subQuestionId: id, selectedOptions: objective ? state.selectedOptions : [], answerText: objective ? '' : state.answerText, selfJudgedCorrect: objective ? undefined : state.selfJudgedCorrect, }; }) .filter(item => item.selectedOptions.length || item.answerText.trim() || item.selfJudgedCorrect !== undefined); } async function handleSubmit(nextSelected = selected, selfCorrect?: boolean) { if (!session || !current) return; if (hasCompositeSubQuestions) { try { const subAnswers = buildSubAnswers(); if (!subAnswers.length) { setError('请至少完成一个子题后再提交。'); return; } const payload = await submitAnswer({ practiceSessionId: session.id, questionId: current.id, subAnswers, }); rememberAnswer(current.id, payload.item, [], ''); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '提交失败'); } return; } if (!isObjectiveQuestion(current) && selfCorrect !== undefined) { try { const payload = await submitAnswer({ practiceSessionId: session.id, questionId: current.id, selectedOptions: [], answerText, selfJudgedCorrect: selfCorrect, }); rememberAnswer(current.id, payload.item); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '提交失败'); } return; } try { const payload = await submitAnswer({ practiceSessionId: session.id, questionId: current.id, selectedOptions: nextSelected, answerText, }); rememberAnswer(current.id, payload.item, nextSelected); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '提交失败'); } } function previousQuestion() { setIndex(prev => Math.max(0, prev - 1)); setCompleted(false); } function nextQuestion() { if (index >= questions.length - 1) { setCompleted(true); return; } setIndex(prev => Math.min(questions.length - 1, prev + 1)); setCompleted(false); } function jumpQuestion(nextIndex: number) { setIndex(Math.max(0, Math.min(questions.length - 1, nextIndex))); setCompleted(false); } async function handleFavorite() { if (!current) return; const nextFavorite = !favoriteByQuestion[current.id]; await toggleQuestionFavorite(current.id, nextFavorite) .then(payload => { setFavoriteByQuestion(prev => ({ ...prev, [current.id]: payload.favorite })); Taro.showToast({ title: payload.favorite ? '已收藏' : '已取消', icon: 'success' }); }) .catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败')); } async function handleFeedback() { if (!current || !feedbackText.trim()) { setError('请先填写反馈内容。'); return; } try { await submitFeedback({ questionId: current.id, type: 'question_error', title: '题目反馈', description: feedbackText.trim(), priority: 'normal', }); setFeedbackText(''); Taro.showToast({ title: '已提交反馈', icon: 'success' }); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '反馈提交失败'); } } async function handleSubmitSession(auto = false) { if (!session) return; if (!auto) { const ok = await Taro.showModal({ title: '交卷', content: `已答 ${answeredCount}/${questions.length},确认交卷并生成练习报告?`, confirmText: '交卷', cancelText: '取消', }); if (!ok.confirm) return; } try { const payload = await submitPracticeSession(session.id); setReport(payload.item || null); setCompleted(true); Taro.showToast({ title: '报告已生成', icon: 'success' }); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '交卷失败'); } } function openReport() { if (!session) return; Taro.navigateTo({ url: `/pages/student/reports/index?practiceSessionId=${session.id}` }); } function openVideo() { if (!current) return; Taro.navigateTo({ url: `/pages/student/video/index?questionId=${current.id}` }); } function subResultFor(subQuestion: Record, subQuestionIndex: number) { const id = subQuestionId(subQuestion, subQuestionIndex); return (answerState?.subResults || []).find(item => item.subQuestionId === id); } return ( {session ? `${index + 1}/${questions.length || session.questionCount}` : 'Practice'} {session?.mode || '练习'} 题目来自后端 session 快照,答题、交卷、错题和报告都以后端校验为准。 {timeLeft !== null ? {formatDuration(timeLeft)} : null} {session ? ( 进度 {answeredCount}/{questions.length} {progressPercent}% 正确 {correctCount} · 错误 {wrongCount} · 未答 {Math.max(0, questions.length - answeredCount)} · {session.accessMode || 'access'} {session.consumedFreeQuota ? 本次消耗免费题量 {session.consumedFreeQuota} : null} ) : null} {report ? ( {String(report.score)} / {String(report.totalScore)} 正确率 {Math.round((report.accuracy || 0) * 100)}% · 已答 {report.answeredCount}/{report.totalQuestions} ) : null} {completed && !report ? ( 已完成本组练习 已答 {answeredCount}/{questions.length} · 正确 {correctCount} · 错误 {wrongCount} ) : null} {loading ? 练习加载中... : null} {current && !completed ? ( {current.typeLabel || current.type || '题目'} {answerLabel(answerState)} {current.content || '未提供题干'} {current.mediaUrl ? {current.mediaUrl} : null} {hasCompositeSubQuestions ? 包含 {subQuestions.length} 个子题,请结合题干作答。 : null} {hasCompositeSubQuestions ? ( {subQuestions.map((subQuestion, subQuestionIndex) => { const id = subQuestionId(subQuestion, subQuestionIndex); const localAnswer = subAnswerById[id] || { selectedOptions: [], answerText: '' }; const objective = isObjectiveSubQuestion(subQuestion); const subOptions = subQuestionOptions(subQuestion); const result = subResultFor(subQuestion, subQuestionIndex); return ( 第 {subQuestionIndex + 1} 小题 · {String(subQuestion.typeLabel || subQuestion.type || '子题')} {result ? {result.isCorrect === true ? '正确' : result.isCorrect === false ? '错误' : '已答'} : null} {String(subQuestion.content || '未提供子题题干')} {objective ? ( {subOptions.map((option, optionIndex) => ( toggleSubOption(subQuestion, subQuestionIndex, optionIndex)} > {String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)} ))} ) : (