forked from wangziqi/gongxue-base
feat: support composite practice questions
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user