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