forked from wangziqi/gongxue-base
656 lines
29 KiB
TypeScript
656 lines
29 KiB
TypeScript
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<string, unknown>;
|
||
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<string, unknown>, 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<string, unknown>) {
|
||
return String(subQuestion.type || 'choice').toLowerCase();
|
||
}
|
||
|
||
function subQuestionOptions(subQuestion: Record<string, unknown>) {
|
||
return Array.isArray(subQuestion.options) ? subQuestion.options : [];
|
||
}
|
||
|
||
function isObjectiveSubQuestion(subQuestion: Record<string, unknown>) {
|
||
const type = subQuestionType(subQuestion);
|
||
return ['choice', 'multi', 'judge', 'image'].includes(type) || subQuestionOptions(subQuestion).length > 0;
|
||
}
|
||
|
||
function isMultiSubQuestion(subQuestion: Record<string, unknown>) {
|
||
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<string, AnswerResult>): Record<string, AnswerState> {
|
||
const result: Record<string, AnswerState> = {};
|
||
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<string, LocalSubAnswer> {
|
||
const result: Record<string, LocalSubAnswer> = {};
|
||
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<PracticeSession | null>(null);
|
||
const [questions, setQuestions] = useState<QuestionItem[]>([]);
|
||
const [index, setIndex] = useState(0);
|
||
const [selected, setSelected] = useState<string[]>([]);
|
||
const [answerText, setAnswerText] = useState('');
|
||
const [subAnswerById, setSubAnswerById] = useState<Record<string, LocalSubAnswer>>({});
|
||
const [feedbackText, setFeedbackText] = useState('');
|
||
const [answerByQuestion, setAnswerByQuestion] = useState<Record<string, AnswerState>>({});
|
||
const [favoriteByQuestion, setFavoriteByQuestion] = useState<Record<string, boolean>>({});
|
||
const [showExplanation, setShowExplanation] = useState(false);
|
||
const [completed, setCompleted] = useState(false);
|
||
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [report, setReport] = useState<PracticeReport | null>(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<number>(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<number>(indexStorageKey(nextSession.id));
|
||
const savedAnswers = getStorage<Record<string, AnswerState>>(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<string, unknown>[],
|
||
[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<LocalSubAnswer>) {
|
||
setSubAnswerById(prev => {
|
||
const currentState = prev[subQuestionId] || { selectedOptions: [], answerText: '' };
|
||
return {
|
||
...prev,
|
||
[subQuestionId]: {
|
||
...currentState,
|
||
...patch,
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
function toggleSubOption(subQuestion: Record<string, unknown>, 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<string, unknown>, subQuestionIndex: number) {
|
||
const id = subQuestionId(subQuestion, subQuestionIndex);
|
||
return (answerState?.subResults || []).find(item => item.subQuestionId === id);
|
||
}
|
||
|
||
return (
|
||
<View className='student-page'>
|
||
<View className='student-topbar'>
|
||
<View className='student-title-block'>
|
||
<Text className='student-kicker'>{session ? `${index + 1}/${questions.length || session.questionCount}` : 'Practice'}</Text>
|
||
<Text className='student-title'>{session?.mode || '练习'}</Text>
|
||
<Text className='student-subtitle'>题目来自后端 session 快照,答题、交卷、错题和报告都以后端校验为准。</Text>
|
||
</View>
|
||
{timeLeft !== null ? <Text className='status-badge'>{formatDuration(timeLeft)}</Text> : null}
|
||
</View>
|
||
|
||
{session ? (
|
||
<View className='quiet-panel section-block'>
|
||
<View className='amount-row'>
|
||
<Text className='row-main'>进度 {answeredCount}/{questions.length}</Text>
|
||
<Text className='status-badge'>{progressPercent}%</Text>
|
||
</View>
|
||
<Text className='row-meta'>正确 {correctCount} · 错误 {wrongCount} · 未答 {Math.max(0, questions.length - answeredCount)} · {session.accessMode || 'access'}</Text>
|
||
{session.consumedFreeQuota ? <Text className='row-meta'>本次消耗免费题量 {session.consumedFreeQuota}</Text> : null}
|
||
</View>
|
||
) : null}
|
||
|
||
{report ? (
|
||
<View className='report-panel section-block'>
|
||
<Text className='metric-value'>{String(report.score)} / {String(report.totalScore)}</Text>
|
||
<Text className='metric-label'>正确率 {Math.round((report.accuracy || 0) * 100)}% · 已答 {report.answeredCount}/{report.totalQuestions}</Text>
|
||
<View className='toolbar'>
|
||
<Button className='primary-button' onClick={openReport}>查看报告</Button>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
|
||
{completed && !report ? (
|
||
<View className='report-panel section-block'>
|
||
<Text className='metric-value'>已完成本组练习</Text>
|
||
<Text className='metric-label'>已答 {answeredCount}/{questions.length} · 正确 {correctCount} · 错误 {wrongCount}</Text>
|
||
<View className='toolbar wrap'>
|
||
<Button className='primary-button' onClick={() => handleSubmitSession()}>生成报告</Button>
|
||
<Button className='secondary-button' onClick={() => jumpQuestion(0)}>重新查看</Button>
|
||
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/review/index?type=wrong' })}>查看错题</Button>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
|
||
{loading ? <View className='empty-state'>练习加载中...</View> : null}
|
||
|
||
{current && !completed ? (
|
||
<View className='list-stack'>
|
||
<View className='quiet-panel'>
|
||
<View className='amount-row'>
|
||
<Text className='row-meta'>{current.typeLabel || current.type || '题目'}</Text>
|
||
<Text className='status-badge'>{answerLabel(answerState)}</Text>
|
||
</View>
|
||
<Text className='row-main'>{current.content || '未提供题干'}</Text>
|
||
{current.mediaUrl ? <Text className='row-meta break-text'>{current.mediaUrl}</Text> : null}
|
||
{hasCompositeSubQuestions ? <Text className='row-meta'>包含 {subQuestions.length} 个子题,请结合题干作答。</Text> : null}
|
||
</View>
|
||
{hasCompositeSubQuestions ? (
|
||
<View className='list-stack'>
|
||
{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 (
|
||
<View className='quiet-panel composite-subquestion' key={id}>
|
||
<View className='amount-row'>
|
||
<Text className='row-meta'>第 {subQuestionIndex + 1} 小题 · {String(subQuestion.typeLabel || subQuestion.type || '子题')}</Text>
|
||
{result ? <Text className='status-badge'>{result.isCorrect === true ? '正确' : result.isCorrect === false ? '错误' : '已答'}</Text> : null}
|
||
</View>
|
||
<Text className='row-main'>{String(subQuestion.content || '未提供子题题干')}</Text>
|
||
{objective ? (
|
||
<View className='sub-option-list'>
|
||
{subOptions.map((option, optionIndex) => (
|
||
<View
|
||
className={`list-row ${localAnswer.selectedOptions.includes(String(optionIndex)) ? 'active' : ''}`}
|
||
key={`${id}-${optionIndex}`}
|
||
onClick={() => toggleSubOption(subQuestion, subQuestionIndex, optionIndex)}
|
||
>
|
||
<Text className='row-main'>{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : (
|
||
<View>
|
||
<Textarea
|
||
className='textarea'
|
||
placeholder='请输入本小题答案,查看参考答案后可自评。'
|
||
value={localAnswer.answerText}
|
||
disabled={isSubmitted}
|
||
onInput={event => updateSubAnswer(id, { answerText: String(event.detail.value || '') })}
|
||
/>
|
||
{!isSubmitted ? (
|
||
<View className='toolbar wrap'>
|
||
<Button className={localAnswer.selfJudgedCorrect === true ? 'pill-button active' : 'pill-button'} onClick={() => updateSubAnswer(id, { selfJudgedCorrect: true })}>答对了</Button>
|
||
<Button className={localAnswer.selfJudgedCorrect === false ? 'pill-button warn' : 'pill-button'} onClick={() => updateSubAnswer(id, { selfJudgedCorrect: false })}>答错了</Button>
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
)}
|
||
{showExplanation || isSubmitted ? (
|
||
<View className='sub-explanation'>
|
||
{result?.correctOptionIndices?.length ? <Text className='row-meta'>正确选项:{result.correctOptionIndices.map(item => String.fromCharCode(65 + item)).join('、')}</Text> : null}
|
||
{result?.correctOptionIndex !== null && result?.correctOptionIndex !== undefined ? <Text className='row-meta'>正确选项:{String.fromCharCode(65 + result.correctOptionIndex)}</Text> : null}
|
||
{result?.correctAnswerText ? <Text className='row-meta'>参考答案:{result.correctAnswerText}</Text> : null}
|
||
{result?.explanation ? <Text className='row-meta'>{result.explanation}</Text> : null}
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
) : options.length ? options.map((option, optionIndex) => (
|
||
<View
|
||
className={`list-row ${selected.includes(String(optionIndex)) ? 'active' : ''}`}
|
||
key={String(optionIndex)}
|
||
onClick={() => toggleOption(optionIndex)}
|
||
>
|
||
<Text className='row-main'>{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)}</Text>
|
||
</View>
|
||
)) : (
|
||
<View className='quiet-panel'>
|
||
<Textarea className='textarea' placeholder='请输入答案或先思考后查看参考答案' value={answerText} onInput={event => setAnswerText(String(event.detail.value || ''))} />
|
||
<View className='toolbar wrap'>
|
||
<Button className='secondary-button' onClick={() => setShowExplanation(true)}>查看答案</Button>
|
||
<Button className='primary-button' onClick={() => handleSubmit(selected, true)}>答对了</Button>
|
||
<Button className='danger-button' onClick={() => handleSubmit(selected, false)}>答错了</Button>
|
||
</View>
|
||
</View>
|
||
)}
|
||
{showExplanation || isSubmitted ? (
|
||
<View className='quiet-panel'>
|
||
{isSubmitted ? <Text className={answerState?.isCorrect ? 'success-text' : answerState?.isCorrect === false ? 'error-text' : 'row-meta'}>{answerLabel(answerState)}</Text> : null}
|
||
{answerState?.subResults?.length ? <Text className='row-meta'>子题正确 {answerState.subResults.filter(item => item.isCorrect === true).length}/{answerState.subResults.length}</Text> : null}
|
||
{current.answerText ? <Text className='row-main'>参考答案:{current.answerText}</Text> : null}
|
||
{current.correctOptionIndices?.length ? <Text className='row-meta'>正确选项:{current.correctOptionIndices.map(item => String.fromCharCode(65 + item)).join('、')}</Text> : null}
|
||
{current.correctOptionIndex !== null && current.correctOptionIndex !== undefined ? <Text className='row-meta'>正确选项:{String.fromCharCode(65 + current.correctOptionIndex)}</Text> : null}
|
||
{current.explanation ? <Text className='row-meta'>{current.explanation}</Text> : null}
|
||
</View>
|
||
) : null}
|
||
<View className='toolbar wrap'>
|
||
{isObjectiveQuestion(current) || hasCompositeSubQuestions ? <Button className='primary-button' onClick={() => handleSubmit()}>提交</Button> : null}
|
||
<Button className='secondary-button' onClick={previousQuestion}>上一题</Button>
|
||
<Button className='secondary-button' onClick={handleFavorite}>{favoriteByQuestion[current.id] ? '取消收藏' : '收藏'}</Button>
|
||
{current.hasVideoExplanation ? <Button className='secondary-button' onClick={openVideo}>视频</Button> : null}
|
||
<Button className='secondary-button' onClick={nextQuestion}>下一题</Button>
|
||
<Button className='secondary-button' onClick={() => handleSubmitSession()}>交卷</Button>
|
||
</View>
|
||
<View className='quiet-panel'>
|
||
<Text className='row-main'>题目反馈</Text>
|
||
<Textarea className='textarea' placeholder='题干、答案、解析或视频存在问题,可以在这里提交给后台处理。' value={feedbackText} onInput={event => setFeedbackText(String(event.detail.value || ''))} />
|
||
<View className='toolbar'>
|
||
<Button className='secondary-button' onClick={handleFeedback}>提交反馈</Button>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
) : !loading && !completed ? <View className='empty-state'>暂无题目。请确认后台已发布题目并配置集合或练习蓝图。</View> : null}
|
||
|
||
{questions.length ? (
|
||
<View className='section-block'>
|
||
<Text className='section-heading'>答题卡</Text>
|
||
<View className='answer-sheet'>
|
||
{questions.map((item, cardIndex) => (
|
||
<Button
|
||
key={item.id}
|
||
className={`answer-card ${cardIndex === index ? 'current' : ''} ${answerByQuestion[item.id] ? 'answered' : ''} ${answerByQuestion[item.id]?.isCorrect === false ? 'wrong' : ''}`}
|
||
onClick={() => jumpQuestion(cardIndex)}
|
||
>
|
||
{cardIndex + 1}
|
||
</Button>
|
||
))}
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||
</View>
|
||
);
|
||
}
|