Files
gongxue-base/apps/taro/src/pages/student/practice/index.tsx
2026-06-29 15:57:03 +08:00

656 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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