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

@@ -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<T>(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<string, unknown>) {
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<string, CompositeSubAnswer>();
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<string, unknown>, 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<QuestionAnswerRow>(
`
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<Record<string, unknown> & { questionId: string }>).map(answer => [answer.questionId, {
...answer,
selectedOptions: normalizeStringArray(answer.selectedOptions),
answerPayload: jsonObject(answer.answerPayload),
}]),
);

View File

@@ -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 个人中心、会员、订单、签到、激活码、勋章
```
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善阅读理解/案例分析多小题、长题干排版、公式图片混排、支付容器体验和小程序兼容。
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善长题干排版、公式图片混排、支付容器体验和小程序兼容。
学生端商城链路的安全边界:

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}

View File

@@ -139,6 +139,23 @@
background: #eff6ff;
}
.composite-subquestion {
margin-top: 0;
}
.sub-option-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.sub-explanation {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.row-main {
display: block;
color: #111827;

View File

@@ -28,6 +28,39 @@ export interface QuestionItem {
createdAt?: string;
}
export interface SubAnswerInput {
subQuestionId: string;
selectedOptions?: string[];
answerText?: string;
selfJudgedCorrect?: boolean;
}
export interface SubAnswerResult {
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;
}
export interface AnswerPayload {
mode?: string;
subAnswers?: SubAnswerInput[];
subResults?: SubAnswerResult[];
summary?: Record<string, unknown>;
}
export interface PracticeSession {
id: string;
mode: string;
@@ -57,8 +90,11 @@ export interface AnswerResult {
isCorrect: boolean | null;
selectedOptions?: string[];
answerText?: string | null;
answerPayload?: AnswerPayload;
answeredAt?: string;
selfJudged?: boolean;
subResults?: SubAnswerResult[];
compositeSummary?: Record<string, unknown>;
}
export interface PracticeReport {
@@ -153,6 +189,7 @@ export async function submitAnswer(body: {
selectedOptions?: string[];
answerText?: string;
selfJudgedCorrect?: boolean;
subAnswers?: SubAnswerInput[];
}) {
return apiRequest<{ item: AnswerResult }>('/api/learning/answers', {
method: 'POST',