diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 0cd6861d..63481bc5 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -17,9 +17,11 @@ import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAcce interface QuestionAnswerRow { question_id: string; question_version_id: string | null; + type: string | null; correct_option_index: number | null; correct_option_indices: unknown; answer_text: string | null; + sub_questions: unknown; } interface PracticeBlueprintRow { @@ -95,6 +97,7 @@ interface PracticeSessionReportAnswerRow { questionId: string; selectedOptions: unknown; answerText: string | null; + answerPayload: unknown; isCorrect: boolean | null; answeredAt: string; } @@ -112,6 +115,56 @@ interface PracticeSessionReportQuestionRow { correctOptionIndex: number | null; correctOptionIndices: unknown; answerText: string | null; + subQuestions: unknown; +} + +interface CompositeSubAnswer { + subQuestionId: string; + selectedOptions: string[]; + answerText: string; + selfJudgedCorrect?: boolean; +} + +interface CompositeSubQuestion { + id: string; + type: string; + typeLabel: string | null; + content: string | null; + options: unknown[]; + correctOptionIndex: number | null; + correctOptionIndices: number[]; + answerText: string | null; + explanation: string | null; + score: number | null; +} + +interface CompositeSubResult { + subQuestionId: string; + order: number; + type: string; + typeLabel: string | null; + content: string | null; + selectedOptions: string[]; + answerText: string | null; + isCorrect: boolean | null; + selfJudged: boolean; + correctOptionIndex: number | null; + correctOptionIndices: number[]; + correctAnswerText: string | null; + explanation: string | null; + answered: boolean; + score: number; + totalScore: number; +} + +interface CompositeAnswerEvaluation { + mode: 'composite'; + subResults: CompositeSubResult[]; + answeredCount: number; + correctCount: number; + wrongCount: number; + unansweredCount: number; + isCorrect: boolean | null; } interface PracticeReportQuestionResult { @@ -130,6 +183,7 @@ interface PracticeReportQuestionResult { correctAnswerText: string | null; explanation: string | null; content: string | null; + subResults?: CompositeSubResult[]; } interface SectionDefinition { @@ -216,17 +270,103 @@ function arraysEqual(left: T[], right: T[]) { return left.length === right.length && left.every((value, index) => value === right[index]); } -function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerText: string) { - const correctIndices = normalizeNumberArray(row.correct_option_indices); - if (correctIndices.length) { +function isObjectiveType(type: string | null | undefined) { + const normalized = String(type || '').toLowerCase(); + return ['choice', 'multi', 'judge', 'image'].includes(normalized); +} + +function normalizeSelectedOptions(value: unknown) { + return normalizeStringArray(value) + .map(item => String(Number(item))) + .filter(item => item !== 'NaN'); +} + +function subQuestionIdAt(index: number, value: Record) { + const raw = value.id ?? value.subQuestionId ?? value.key; + return typeof raw === 'string' && raw.trim() ? raw.trim() : `sub_${index + 1}`; +} + +function normalizeCompositeSubQuestions(value: unknown): CompositeSubQuestion[] { + return jsonArray(value).map((raw, index) => { + const source = jsonObject(raw); + const correctOptionIndices = normalizeNumberArray(source.correctOptionIndices ?? source.correct_option_indices); + const correctOptionIndex = source.correctOptionIndex ?? source.correct_option_index; + const numericCorrectOptionIndex = correctOptionIndex === null || correctOptionIndex === undefined + ? null + : Number(correctOptionIndex); + const score = source.score === undefined || source.score === null ? null : finiteNumber(source.score, 0); + return { + id: subQuestionIdAt(index, source), + type: typeof source.type === 'string' && source.type.trim() ? source.type.trim() : 'choice', + typeLabel: typeof source.typeLabel === 'string' + ? source.typeLabel + : typeof source.type_label === 'string' + ? source.type_label + : null, + content: typeof source.content === 'string' ? source.content : null, + options: jsonArray(source.options), + correctOptionIndex: typeof numericCorrectOptionIndex === 'number' && Number.isFinite(numericCorrectOptionIndex) + ? Math.trunc(numericCorrectOptionIndex) + : null, + correctOptionIndices, + answerText: typeof source.answerText === 'string' + ? source.answerText + : typeof source.answer_text === 'string' + ? source.answer_text + : null, + explanation: typeof source.explanation === 'string' ? source.explanation : null, + score: score === null ? null : round2(score), + }; + }); +} + +function parseSubAnswers(value: unknown): CompositeSubAnswer[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + throw new HttpError(400, 'subAnswers must be an array', 'INVALID_SUB_ANSWERS'); + } + return value.map((raw, index) => { + const source = jsonObject(raw); + const subQuestionId = source.subQuestionId ?? source.id ?? source.key; + if (typeof subQuestionId !== 'string' || !subQuestionId.trim()) { + throw new HttpError(400, `subAnswers[${index}].subQuestionId is required`, 'SUB_ANSWER_ID_REQUIRED'); + } + const selfJudgedCorrect = source.selfJudgedCorrect; + if (selfJudgedCorrect !== undefined && selfJudgedCorrect !== null && typeof selfJudgedCorrect !== 'boolean') { + throw new HttpError(400, `subAnswers[${index}].selfJudgedCorrect must be boolean`, 'INVALID_BOOLEAN_FIELD'); + } + return { + subQuestionId: subQuestionId.trim(), + selectedOptions: normalizeSelectedOptions(source.selectedOptions), + answerText: typeof source.answerText === 'string' ? source.answerText : '', + selfJudgedCorrect: typeof selfJudgedCorrect === 'boolean' ? selfJudgedCorrect : undefined, + }; + }); +} + +function judgeObjectiveAnswer(selectedOptions: string[], correctOptionIndices: number[], correctOptionIndex: number | null) { + if (correctOptionIndices.length) { return arraysEqual( selectedOptions.map(item => Number(item)).filter(Number.isFinite).map(item => Math.trunc(item)).sort((a, b) => a - b), - correctIndices, + correctOptionIndices, ); } + if (correctOptionIndex !== null && correctOptionIndex !== undefined) { + return selectedOptions.length === 1 && Number(selectedOptions[0]) === correctOptionIndex; + } + + return null; +} + +function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerText: string) { + const correctIndices = normalizeNumberArray(row.correct_option_indices); + if (correctIndices.length) { + return judgeObjectiveAnswer(selectedOptions, correctIndices, null); + } + if (row.correct_option_index !== null && row.correct_option_index !== undefined) { - return selectedOptions.length === 1 && Number(selectedOptions[0]) === row.correct_option_index; + return judgeObjectiveAnswer(selectedOptions, [], row.correct_option_index); } if (row.answer_text) { @@ -241,6 +381,187 @@ function hasObjectiveAnswer(row: QuestionAnswerRow) { (row.correct_option_index !== null && row.correct_option_index !== undefined); } +function hasSubObjectiveAnswer(row: CompositeSubQuestion) { + return row.correctOptionIndices.length > 0 || + (row.correctOptionIndex !== null && row.correctOptionIndex !== undefined); +} + +function evaluateCompositeAnswer(row: QuestionAnswerRow, subAnswers: CompositeSubAnswer[]): CompositeAnswerEvaluation { + const subQuestions = normalizeCompositeSubQuestions(row.sub_questions); + if (!subQuestions.length) { + throw new HttpError(400, 'Question does not contain sub questions', 'COMPOSITE_QUESTION_REQUIRED'); + } + + const answerBySubId = new Map(); + for (const answer of subAnswers) { + if (answerBySubId.has(answer.subQuestionId)) { + throw new HttpError(400, `Duplicate sub answer ${answer.subQuestionId}`, 'DUPLICATE_SUB_ANSWER'); + } + answerBySubId.set(answer.subQuestionId, answer); + } + + const allowedIds = new Set(subQuestions.map(item => item.id)); + for (const answer of subAnswers) { + if (!allowedIds.has(answer.subQuestionId)) { + throw new HttpError(400, `Unknown sub question ${answer.subQuestionId}`, 'UNKNOWN_SUB_ANSWER'); + } + } + + const fallbackScore = subQuestions.length ? round2(1 / subQuestions.length) : 0; + const subResults: CompositeSubResult[] = subQuestions.map((subQuestion, index) => { + const answer = answerBySubId.get(subQuestion.id) || null; + const selectedOptions = answer?.selectedOptions || []; + const answerText = answer?.answerText || ''; + const objective = isObjectiveType(subQuestion.type) || hasSubObjectiveAnswer(subQuestion); + const answered = objective ? selectedOptions.length > 0 : Boolean(answerText.trim() || answer?.selfJudgedCorrect !== undefined); + let isCorrect: boolean | null = null; + let selfJudged = false; + + if (answer) { + if (answer.selfJudgedCorrect !== undefined) { + if (objective) { + throw new HttpError(400, 'Self judgment is only allowed for subjective sub questions', 'SELF_JUDGMENT_NOT_ALLOWED'); + } + isCorrect = answer.selfJudgedCorrect; + selfJudged = true; + } else if (objective) { + isCorrect = judgeObjectiveAnswer(selectedOptions, subQuestion.correctOptionIndices, subQuestion.correctOptionIndex); + } else if (subQuestion.answerText) { + isCorrect = normalizeAnswerText(answerText) === normalizeAnswerText(subQuestion.answerText); + } + } + + const totalScore = subQuestion.score !== null ? subQuestion.score : fallbackScore; + return { + subQuestionId: subQuestion.id, + order: index + 1, + type: subQuestion.type, + typeLabel: subQuestion.typeLabel, + content: subQuestion.content, + selectedOptions, + answerText: answered ? answerText : null, + isCorrect, + selfJudged, + correctOptionIndex: subQuestion.correctOptionIndex, + correctOptionIndices: subQuestion.correctOptionIndices, + correctAnswerText: subQuestion.answerText, + explanation: subQuestion.explanation, + answered, + score: isCorrect === true ? round2(totalScore) : 0, + totalScore: round2(totalScore), + }; + }); + + const answeredCount = subResults.filter(item => item.answered).length; + const correctCount = subResults.filter(item => item.isCorrect === true).length; + const wrongCount = subResults.filter(item => item.answered && item.isCorrect !== true).length; + const unansweredCount = Math.max(0, subResults.length - answeredCount); + if (answeredCount === 0) { + throw new HttpError(400, 'At least one sub question must be answered', 'SUB_ANSWERS_EMPTY'); + } + const judgedResults = subResults.filter(item => item.isCorrect !== null); + const isCorrect = subResults.length > 0 && judgedResults.length === subResults.length + ? subResults.every(item => item.isCorrect === true) + : answeredCount > 0 + ? false + : null; + + return { + mode: 'composite', + subResults, + answeredCount, + correctCount, + wrongCount, + unansweredCount, + isCorrect, + }; +} + +function normalizeStoredSubResults(value: unknown): CompositeSubResult[] { + return jsonArray(value).map((raw, index) => { + const source = jsonObject(raw); + const selectedOptions = normalizeStringArray(source.selectedOptions); + const correctOptionIndex = source.correctOptionIndex; + const numericCorrectOptionIndex = correctOptionIndex === null || correctOptionIndex === undefined + ? null + : Number(correctOptionIndex); + const isCorrect = typeof source.isCorrect === 'boolean' ? source.isCorrect : null; + return { + subQuestionId: typeof source.subQuestionId === 'string' && source.subQuestionId.trim() + ? source.subQuestionId.trim() + : `sub_${index + 1}`, + order: Number.isFinite(Number(source.order)) ? Math.trunc(Number(source.order)) : index + 1, + type: typeof source.type === 'string' && source.type.trim() ? source.type.trim() : 'choice', + typeLabel: typeof source.typeLabel === 'string' ? source.typeLabel : null, + content: typeof source.content === 'string' ? source.content : null, + selectedOptions, + answerText: typeof source.answerText === 'string' ? source.answerText : null, + isCorrect, + selfJudged: source.selfJudged === true, + correctOptionIndex: typeof numericCorrectOptionIndex === 'number' && Number.isFinite(numericCorrectOptionIndex) + ? Math.trunc(numericCorrectOptionIndex) + : null, + correctOptionIndices: normalizeNumberArray(source.correctOptionIndices), + correctAnswerText: typeof source.correctAnswerText === 'string' ? source.correctAnswerText : null, + explanation: typeof source.explanation === 'string' ? source.explanation : null, + answered: source.answered === true, + score: round2(finiteNumber(source.score, 0)), + totalScore: round2(finiteNumber(source.totalScore, 0)), + }; + }); +} + +function storedCompositeSubResults(subResults: CompositeSubResult[]) { + return subResults.map(item => ({ + subQuestionId: item.subQuestionId, + order: item.order, + selectedOptions: item.selectedOptions, + answerText: item.answerText, + isCorrect: item.isCorrect, + selfJudged: item.selfJudged, + answered: item.answered, + score: item.score, + totalScore: item.totalScore, + })); +} + +function enrichCompositeSubResults(subResults: CompositeSubResult[], subQuestionsValue: unknown): CompositeSubResult[] { + const subQuestionById = new Map(normalizeCompositeSubQuestions(subQuestionsValue).map(item => [item.id, item])); + return subResults.map(item => { + const subQuestion = subQuestionById.get(item.subQuestionId); + if (!subQuestion) return item; + return { + ...item, + type: subQuestion.type, + typeLabel: subQuestion.typeLabel, + content: subQuestion.content, + correctOptionIndex: subQuestion.correctOptionIndex, + correctOptionIndices: subQuestion.correctOptionIndices, + correctAnswerText: subQuestion.answerText, + explanation: subQuestion.explanation, + }; + }); +} + +function scaleCompositeSubResults(subResults: CompositeSubResult[], questionScore: number) { + if (!subResults.length) return { subResults: [], score: 0 }; + const rawTotal = round2(subResults.reduce((sum, item) => sum + finiteNumber(item.totalScore, 0), 0)); + const fallbackTotalEach = round2(questionScore / Math.max(1, subResults.length)); + const scale = rawTotal > 0 ? questionScore / rawTotal : 1; + const scaled = subResults.map(item => { + const totalScore = rawTotal > 0 ? round2(finiteNumber(item.totalScore, 0) * scale) : fallbackTotalEach; + return { + ...item, + totalScore, + score: item.isCorrect === true ? totalScore : 0, + }; + }); + return { + subResults: scaled, + score: round2(scaled.reduce((sum, item) => sum + item.score, 0)), + }; +} + function optionalBodyBoolean(body: Record, key: string): boolean | undefined { const value = body[key]; if (value === undefined || value === null) return undefined; @@ -737,12 +1058,14 @@ export async function submitAnswerRoute(ctx: RequestContext) { const selectedOptions = optionalStringArray(body, 'selectedOptions'); const answerText = optionalString(body, 'answerText'); const selfJudgedCorrect = optionalBodyBoolean(body, 'selfJudgedCorrect'); + const subAnswers = parseSubAnswers(body.subAnswers); const practiceSessionId = optionalString(body, 'practiceSessionId') || null; const question = await queryOne( ` select q.id as question_id, q.current_version_id as question_version_id, - v.correct_option_index, v.correct_option_indices, v.answer_text + q.type, v.correct_option_index, v.correct_option_indices, v.answer_text, + v.sub_questions from public.questions q left join public.question_versions v on v.id = q.current_version_id where q.tenant_id = $1 and q.id = $2 and q.status = 'published' @@ -757,20 +1080,53 @@ export async function submitAnswerRoute(ctx: RequestContext) { const result = await transaction(async client => { await assertAnswerSessionAccess(client, { tenantId, userId, practiceSessionId, questionId }); - const judged = resolveJudgedAnswer(question, selectedOptions, answerText, selfJudgedCorrect); + const compositeEvaluation = subAnswers.length ? evaluateCompositeAnswer(question, subAnswers) : null; + if (!compositeEvaluation && normalizeCompositeSubQuestions(question.sub_questions).length) { + throw new HttpError(400, 'Composite questions must be answered with subAnswers', 'SUB_ANSWERS_REQUIRED'); + } + if (compositeEvaluation && (selectedOptions.length || answerText || selfJudgedCorrect !== undefined)) { + throw new HttpError(400, 'Composite answers must not mix top-level answer fields', 'MIXED_COMPOSITE_ANSWER'); + } + const judged = compositeEvaluation?.isCorrect ?? resolveJudgedAnswer(question, selectedOptions, answerText, selfJudgedCorrect); + const answerPayload = compositeEvaluation ? { + mode: compositeEvaluation.mode, + subAnswers: subAnswers.map(item => ({ + subQuestionId: item.subQuestionId, + selectedOptions: item.selectedOptions, + answerText: item.answerText || null, + selfJudgedCorrect: item.selfJudgedCorrect, + })), + subResults: storedCompositeSubResults(compositeEvaluation.subResults), + summary: { + answeredCount: compositeEvaluation.answeredCount, + correctCount: compositeEvaluation.correctCount, + wrongCount: compositeEvaluation.wrongCount, + unansweredCount: compositeEvaluation.unansweredCount, + }, + } : {}; const answerResult = await client.query( ` insert into public.answer_records ( tenant_id, user_id, question_id, question_version_id, practice_session_id, - selected_options, answer_text, is_correct + selected_options, answer_text, answer_payload, is_correct ) - values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8) + values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, $9) returning id, question_id as "questionId", question_version_id as "questionVersionId", selected_options as "selectedOptions", answer_text as "answerText", - is_correct as "isCorrect", answered_at as "answeredAt" + answer_payload as "answerPayload", is_correct as "isCorrect", answered_at as "answeredAt" `, - [tenantId, userId, questionId, question.question_version_id, practiceSessionId, JSON.stringify(selectedOptions), answerText || null, judged], + [ + tenantId, + userId, + questionId, + question.question_version_id, + practiceSessionId, + JSON.stringify(selectedOptions), + answerText || null, + JSON.stringify(answerPayload), + judged, + ], ); if (judged === false) { @@ -814,6 +1170,13 @@ export async function submitAnswerRoute(ctx: RequestContext) { return { ...answerResult.rows[0], selfJudged: selfJudgedCorrect !== undefined, + subResults: compositeEvaluation?.subResults, + compositeSummary: compositeEvaluation ? { + answeredCount: compositeEvaluation.answeredCount, + correctCount: compositeEvaluation.correctCount, + wrongCount: compositeEvaluation.wrongCount, + unansweredCount: compositeEvaluation.unansweredCount, + } : undefined, }; }); @@ -938,7 +1301,7 @@ async function buildPracticeSessionReport( v.content, v.explanation, v.correct_option_index as "correctOptionIndex", v.correct_option_indices as "correctOptionIndices", - v.answer_text as "answerText" + v.answer_text as "answerText", v.sub_questions as "subQuestions" from public.questions q left join public.question_versions v on v.id = q.current_version_id left join public.question_collection_items ci @@ -955,7 +1318,8 @@ async function buildPracticeSessionReport( ` select distinct on (question_id) question_id as "questionId", selected_options as "selectedOptions", - answer_text as "answerText", is_correct as "isCorrect", + answer_text as "answerText", answer_payload as "answerPayload", + is_correct as "isCorrect", answered_at as "answeredAt" from public.answer_records where tenant_id = $1 @@ -980,7 +1344,10 @@ async function buildPracticeSessionReport( const questionScore = round2(resolveQuestionScore({ row: question, section, fallbackScore: fallbackQuestionScore })); const answered = !!answer; const isCorrect = answer?.isCorrect ?? null; - const earnedScore = isCorrect === true ? questionScore : 0; + const storedPayload = jsonObject(answer?.answerPayload); + const storedSubResults = enrichCompositeSubResults(normalizeStoredSubResults(storedPayload.subResults), question.subQuestions); + const scaledComposite = storedSubResults.length ? scaleCompositeSubResults(storedSubResults, questionScore) : null; + const earnedScore = scaledComposite ? scaledComposite.score : isCorrect === true ? questionScore : 0; const sectionStat = sectionStatsByKey.get(section.key) || { key: section.key, title: section.title, @@ -1026,6 +1393,7 @@ async function buildPracticeSessionReport( correctAnswerText: question.answerText, explanation: question.explanation, content: question.content, + subResults: scaledComposite?.subResults, }); } @@ -1276,7 +1644,7 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) { select distinct on (question_id) id, question_id as "questionId", question_version_id as "questionVersionId", selected_options as "selectedOptions", answer_text as "answerText", - is_correct as "isCorrect", answered_at as "answeredAt" + answer_payload as "answerPayload", is_correct as "isCorrect", answered_at as "answeredAt" from public.answer_records where tenant_id = $1 and user_id = $2 @@ -1293,6 +1661,7 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) { (answers as Array & { questionId: string }>).map(answer => [answer.questionId, { ...answer, selectedOptions: normalizeStringArray(answer.selectedOptions), + answerPayload: jsonObject(answer.answerPayload), }]), ); diff --git a/apps/taro/README.md b/apps/taro/README.md index fc36b93e..f9a3881a 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -30,7 +30,7 @@ pages/student/login/index 短信登录 pages/student/home/index 首页与功能入口 pages/student/region/index 地区选择、目标地区保存 pages/student/catalog/index 题库入口、分类、集合、练习蓝图 -pages/student/practice/index 创建练习 session、答题卡、进度恢复、倒计时、客观题判分、主观题自评、收藏、反馈、视频入口、交卷报告 +pages/student/practice/index 创建练习 session、答题卡、进度恢复、倒计时、客观题判分、主观题自评、阅读理解/案例分析多小题、收藏、反馈、视频入口、交卷报告 pages/student/review/index 错题本、收藏夹、错题/收藏复习 pages/student/reports/index 练习报告、模考报告、历史报告 pages/student/video/index 题目视频解析、播放签名 @@ -43,7 +43,7 @@ pages/student/assets/index 资料列表、预览签名、下载签名 pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章 ``` -这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善阅读理解/案例分析多小题、长题干排版、公式图片混排、支付容器体验和小程序兼容。 +这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善长题干排版、公式图片混排、支付容器体验和小程序兼容。 学生端商城链路的安全边界: diff --git a/apps/taro/src/pages/student/practice/index.tsx b/apps/taro/src/pages/student/practice/index.tsx index 5340603c..cc1e9c3f 100644 --- a/apps/taro/src/pages/student/practice/index.tsx +++ b/apps/taro/src/pages/student/practice/index.tsx @@ -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, 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`; } @@ -76,11 +110,34 @@ function answersFromBackend(answers?: Record): 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(); @@ -96,6 +153,7 @@ export default function StudentPracticePage() { 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>({}); @@ -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[], + [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) { + 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({ @@ -339,6 +469,11 @@ export default function StudentPracticePage() { 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 ( @@ -394,9 +529,65 @@ export default function StudentPracticePage() { {current.content || '未提供题干'} {current.mediaUrl ? {current.mediaUrl} : null} - {current.subQuestions?.length ? 包含 {current.subQuestions.length} 个子题,请结合题干作答。 : null} + {hasCompositeSubQuestions ? 包含 {subQuestions.length} 个子题,请结合题干作答。 : null} - {options.length ? options.map((option, optionIndex) => ( + {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)} + + ))} + + ) : ( + +