feat: support composite practice questions

This commit is contained in:
Codex
2026-06-29 15:57:03 +08:00
parent 07a6edeea2
commit e54efbc294
14 changed files with 893 additions and 43 deletions

View File

@@ -5,6 +5,8 @@ import { loadCollectionQuestions, loadQuestions } from '@/services/catalog';
import {
createPracticeSession,
loadPracticeSessionDetail,
type SubAnswerInput,
type SubAnswerResult,
submitAnswer,
submitPracticeSession,
toggleQuestionFavorite,
@@ -23,6 +25,14 @@ type AnswerState = {
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) {
@@ -45,6 +55,30 @@ function isMultiQuestion(question?: QuestionItem | null) {
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`;
}
@@ -76,11 +110,34 @@ function answersFromBackend(answers?: Record<string, AnswerResult>): Record<stri
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();
@@ -96,6 +153,7 @@ export default function StudentPracticePage() {
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>>({});
@@ -165,6 +223,11 @@ export default function StudentPracticePage() {
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;
@@ -187,6 +250,7 @@ export default function StudentPracticePage() {
const state = answerByQuestion[current.id];
setSelected(state?.selectedOptions || []);
setAnswerText(state?.answerText || '');
setSubAnswerById(subAnswerStateFrom(state));
setFeedbackText('');
setShowExplanation(!!state);
}, [current?.id]);
@@ -213,6 +277,12 @@ export default function StudentPracticePage() {
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);
@@ -228,8 +298,68 @@ export default function StudentPracticePage() {
}
}
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({
@@ -339,6 +469,11 @@ export default function StudentPracticePage() {
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'>
@@ -394,9 +529,65 @@ export default function StudentPracticePage() {
</View>
<Text className='row-main'>{current.content || '未提供题干'}</Text>
{current.mediaUrl ? <Text className='row-meta break-text'>{current.mediaUrl}</Text> : null}
{current.subQuestions?.length ? <Text className='row-meta'> {current.subQuestions.length} </Text> : null}
{hasCompositeSubQuestions ? <Text className='row-meta'> {subQuestions.length} </Text> : null}
</View>
{options.length ? options.map((option, optionIndex) => (
{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)}
@@ -417,6 +608,7 @@ export default function StudentPracticePage() {
{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}
@@ -424,7 +616,7 @@ export default function StudentPracticePage() {
</View>
) : null}
<View className='toolbar wrap'>
{isObjectiveQuestion(current) ? <Button className='primary-button' onClick={() => handleSubmit()}></Button> : null}
{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}