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',
|
||||
|
||||
@@ -57,11 +57,11 @@
|
||||
| 题目列表/集合 | 可联调 | `/api/catalog/question-collections`、`question-collections/questions` |
|
||||
| 顺序/随机/全真模拟规则 | 可联调 | `/api/catalog/practice-blueprints` |
|
||||
| 创建练习 session | 可联调 | `POST /api/learning/practice-sessions`;后端强制校验免费额度、SVIP 范围和内容访问规则 |
|
||||
| 答题记录 | 可联调 | `POST /api/learning/answers`;题目必须属于本人有效 session 快照 |
|
||||
| 答题记录 | 可联调 | `POST /api/learning/answers`;题目必须属于本人有效 session 快照;客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析用 `subAnswers` 保存和判分每个子题 |
|
||||
| 错题本 | 可联调 | `/api/learning/wrong-questions` |
|
||||
| 收藏夹 | 可联调 | `/api/learning/favorites/questions` |
|
||||
| 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 |
|
||||
| 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,重复提交幂等 |
|
||||
| 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,复合题返回 `questionResults[].subResults` 和部分得分,重复提交幂等 |
|
||||
| 学习历史/统计/趋势 | 可联调 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend`;可支撑个人中心、练习历史、正确率趋势和题型分布 |
|
||||
| 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions` 的 `mode=wrong_review`,后端从本人错题本安全组卷 |
|
||||
| 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;支持 `questions`、`score`、`vocabulary`、`mock_exam` 四类指标,支持 `all`、`7d`、`30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session |
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 长题干/阅读理解/案例分析多小题体验、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 长题干/公式图片混排体验、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||
@@ -35,7 +35,7 @@
|
||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/多小题刷题 UI、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
|
||||
## 前端接入建议
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补刷题细节 UI、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||
|
||||
### P1:商用收费和运营能力
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:长题干/阅读理解/案例分析多小题、勋章展示和小程序端分享/支付体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:长题干、公式图片混排、勋章展示和小程序端分享/支付体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
- `content_entries`
|
||||
- `content_nodes`
|
||||
@@ -66,7 +66,7 @@
|
||||
| 首页 | `apps/taro/src/pages/student/home/index.tsx` | `content-entries`、`banners`、`announcements`、`profile/me` |
|
||||
| 地区选择 | `apps/taro/src/pages/student/region/index.tsx` | `catalog/regions`、`profile/me`、`PATCH profile/me` |
|
||||
| 题库 | `apps/taro/src/pages/student/catalog/index.tsx` | `content-entries`、`content-nodes`、`question-collections`、`practice-blueprints` |
|
||||
| 练习 | `apps/taro/src/pages/student/practice/index.tsx` | `practice-sessions`、`questions`、`answers`、`favorites/questions`、`practice-sessions/submit`、`profile/feedbacks`;已接答题卡、后端 session detail 恢复、本地进度恢复、倒计时、主观题 `selfJudgedCorrect` |
|
||||
| 练习 | `apps/taro/src/pages/student/practice/index.tsx` | `practice-sessions`、`questions`、`answers`、`favorites/questions`、`practice-sessions/submit`、`profile/feedbacks`;已接答题卡、后端 session detail 恢复、本地进度恢复、倒计时、主观题 `selfJudgedCorrect`、阅读理解/案例分析 `subAnswers` 多小题 |
|
||||
| 错题/收藏 | `apps/taro/src/pages/student/review/index.tsx` | `wrong-questions/review-plan`、`wrong-questions/resolve`、`favorites/questions`、`practice-sessions` |
|
||||
| 练习报告 | `apps/taro/src/pages/student/reports/index.tsx` | `practice-sessions/report`、`practice-reports` |
|
||||
| 视频解析 | `apps/taro/src/pages/student/video/index.tsx` | `questions/videos`、`videos/play` |
|
||||
@@ -78,7 +78,7 @@
|
||||
| 资料 | `apps/taro/src/pages/student/assets/index.tsx` | `assets`、`assets/preview`、`assets/download` |
|
||||
| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`leaderboard` |
|
||||
|
||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/收藏/反馈/视频 -> 交卷报告 -> 错题/收藏复习”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补阅读理解/案例分析多小题、小程序分享/支付容器体验。
|
||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告 -> 错题/收藏复习”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补长题干/公式图片混排、小程序分享/支付容器体验。
|
||||
|
||||
## 已落地的 Taro 租户后台页面
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 |
|
||||
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、导入、导出权限/脱敏、公共题库授权、采纳后组卷、同步新增题、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳/手动/自动同步、冲突查询/处理、导入后复检、模板下载、字段映射 API 和导出基础可联调;PDF/Word 导出 worker、公共题库版本通知和运营消息仍需补齐 |
|
||||
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、公共题库授权、采纳后组卷、同步新增题、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题第一版可联调,公共题库采纳/手动/自动同步、冲突查询/处理、导入后复检、模板下载、字段映射 API 和导出基础可联调;PDF/Word 导出 worker、公共题库版本通知和运营消息仍需补齐 |
|
||||
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、考试倒计时、入口、个人统计有基础;缺完整运营动态和学习任务聚合 |
|
||||
| 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
|
||||
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
|
||||
| 顺序刷题 | `pages/Quiz.tsx` | 部分覆盖 | 免费额度/SVIP 校验、session 快照、练习历史、趋势统计和题目反馈已由后端强制;Taro 已有答题卡、后端 session detail 续练、本地进度恢复、客观题自动提交和主观题后端自评第一版;继续补长题干和多小题体验 |
|
||||
| 顺序刷题 | `pages/Quiz.tsx` | 部分覆盖 | 免费额度/SVIP 校验、session 快照、练习历史、趋势统计和题目反馈已由后端强制;Taro 已有答题卡、后端 session detail 续练、本地进度恢复、客观题自动提交、主观题后端自评、阅读理解/案例分析多小题作答和报告明细第一版;继续补长题干、公式图片混排和更完整复盘体验 |
|
||||
| 随机刷题 | `pages/Quiz.tsx` | 部分覆盖 | 已有 blueprint/session 快照、访问控制和历史统计;Taro 已按 session 题目快照渲染第一版;继续补更完整复盘和随机刷题状态管理 |
|
||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、倒计时、交卷评分、分段统计和错题解析汇总已覆盖;后续补排行榜/排名、完整复盘体验 |
|
||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、倒计时、交卷评分、分段统计、错题解析汇总和复合题子题部分得分已覆盖;后续补排行榜/排名、完整复盘体验 |
|
||||
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 |
|
||||
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
||||
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
|
||||
@@ -95,15 +95,16 @@
|
||||
|
||||
这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能:
|
||||
|
||||
1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
||||
2. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
||||
3. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。
|
||||
4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
||||
5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需版本通知和运营后台消息。
|
||||
6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||
7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
||||
8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
||||
9. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。
|
||||
1. 阅读理解/案例分析多小题:后端 `subAnswers` 判分、断点恢复、报告 `subResults` 和 Taro 作答 UI 第一版已完成;仍需优化长题干排版、公式图片混排和复盘体验。
|
||||
2. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
||||
3. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
||||
4. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。
|
||||
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需版本通知和运营后台消息。
|
||||
7. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||
8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
||||
9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
||||
10. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。
|
||||
|
||||
### P0:前端联调到云端前
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
- 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。
|
||||
- 已完成单词复习算法、每日计划和复习上报。
|
||||
- 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。
|
||||
- Taro 已有后端 session detail 续练、本地断点恢复和倒计时第一版;继续补复盘体验和多小题统计口径。
|
||||
- Taro 已有后端 session detail 续练、本地断点恢复、倒计时和阅读理解/案例分析多小题第一版;继续补复盘体验、长题干/公式图片混排和更细统计口径。
|
||||
|
||||
8. 订单和营销体验
|
||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
||||
@@ -165,7 +165,7 @@
|
||||
- H5 和小程序共用同一套业务 API client。
|
||||
- 租户通过域名、小程序配置或启动参数解析。
|
||||
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补阅读理解/案例分析多小题、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补长题干/公式图片混排、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
|
||||
### 第一批页面
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补阅读理解/案例分析多小题、长题干排版、小程序支付容器、分享场景和状态管理。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补长题干排版、公式图片混排、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
|
||||
@@ -254,6 +254,11 @@ GET /api/learning/practice-sessions/detail?practiceSessionId=<sessionId>
|
||||
"<questionId>": {
|
||||
"selectedOptions": ["1"],
|
||||
"answerText": null,
|
||||
"answerPayload": {
|
||||
"mode": "composite",
|
||||
"subAnswers": [],
|
||||
"subResults": []
|
||||
},
|
||||
"isCorrect": true,
|
||||
"answeredAt": "..."
|
||||
}
|
||||
@@ -268,6 +273,7 @@ GET /api/learning/practice-sessions/detail?practiceSessionId=<sessionId>
|
||||
- 继续练习入口优先从 `GET /api/learning/practice-sessions/history?status=active` 获取未完成 session,再带 `practiceSessionId` 进入练习页。
|
||||
- 练习页如果 URL 有 `practiceSessionId`,先调用 detail 恢复后端题目快照和最新答案,不要新建 session。
|
||||
- `answersByQuestion` 是同一题的最新答题记录,答题卡、正确/错误统计和解析展示以它为准。
|
||||
- 阅读理解、案例分析等复合题会在 `answerPayload.subAnswers/subResults` 中返回子题作答、判分、解析和分值;继续练习时按该结构恢复每个子题状态。
|
||||
- 倒计时以 `expiresAt` 计算剩余时间;不要用本地启动时间重新生成考试时长。
|
||||
- detail 只返回当前用户自己的 session;跨用户或跨租户读取会返回 `PRACTICE_SESSION_NOT_FOUND`。
|
||||
|
||||
@@ -310,6 +316,61 @@ POST /api/learning/answers
|
||||
}
|
||||
```
|
||||
|
||||
阅读理解、案例分析、组合题等带 `subQuestions` 的复合题必须使用 `subAnswers`,不能混用顶层 `selectedOptions/answerText/selfJudgedCorrect`:
|
||||
|
||||
```json
|
||||
{
|
||||
"practiceSessionId": "...",
|
||||
"questionId": "...",
|
||||
"subAnswers": [
|
||||
{
|
||||
"subQuestionId": "main-idea",
|
||||
"selectedOptions": ["1"]
|
||||
},
|
||||
{
|
||||
"subQuestionId": "reason",
|
||||
"answerText": "学生自己的作答或备注",
|
||||
"selfJudgedCorrect": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
复合题响应会额外返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"item": {
|
||||
"isCorrect": true,
|
||||
"answerPayload": {
|
||||
"mode": "composite",
|
||||
"subAnswers": [
|
||||
{ "subQuestionId": "main-idea", "selectedOptions": ["1"], "answerText": null },
|
||||
{ "subQuestionId": "reason", "selectedOptions": [], "answerText": "学生自己的作答或备注", "selfJudgedCorrect": true }
|
||||
],
|
||||
"subResults": [
|
||||
{
|
||||
"subQuestionId": "main-idea",
|
||||
"order": 1,
|
||||
"type": "choice",
|
||||
"selectedOptions": ["1"],
|
||||
"isCorrect": true,
|
||||
"correctOptionIndices": [1],
|
||||
"explanation": "..."
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"answeredCount": 2,
|
||||
"correctCount": 2,
|
||||
"wrongCount": 0,
|
||||
"unansweredCount": 0
|
||||
}
|
||||
},
|
||||
"subResults": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应关键字段:
|
||||
|
||||
```json
|
||||
@@ -329,6 +390,8 @@ POST /api/learning/answers
|
||||
前端处理规则:
|
||||
|
||||
- 客观题不要传 `selfJudgedCorrect`。后端会用题库标准答案判分,传了会返回 `SELF_JUDGMENT_NOT_ALLOWED`。
|
||||
- 客观子题同样不要传 `selfJudgedCorrect`;主观子题可以传 `selfJudgedCorrect`。
|
||||
- 复合题如果缺少 `subAnswers` 会返回 `SUB_ANSWERS_REQUIRED`;空提交会返回 `SUB_ANSWERS_EMPTY`;未知子题 id 会返回 `UNKNOWN_SUB_ANSWER`。
|
||||
- 主观题自评也由后端落库为 `answer_records.is_correct`,错题本、练习统计、模考报告都以后端返回为准。
|
||||
- 前端可以在本地缓存当前 session 的答题卡和当前题号,用于刷新恢复体验;但交卷报告只以后端 `answer_records` 和 session 快照计算。
|
||||
- `answerText` 只保存学生作答或备注,不要为了让后端判对而把参考答案塞进去。
|
||||
@@ -430,6 +493,13 @@ POST /api/tenant-admin/badge-grants
|
||||
}
|
||||
```
|
||||
|
||||
复合题报告规则:
|
||||
|
||||
- `totalQuestions` 仍按顶层大题计数,阅读理解/案例分析不会按子题拆成多题。
|
||||
- `questionResults[].subResults` 返回每个子题的 `selectedOptions/answerText/isCorrect/explanation/score/totalScore`。
|
||||
- 若导入数据没有给子题分值,后端默认把该大题分值平均分给所有子题;如果后续导入模板提供子题 `score`,报告会按子题分值再缩放到大题配置分。
|
||||
- 顶层 `isCorrect=true` 表示所有子题都判为正确;若部分正确,顶层为 `false`,但 `score` 会保留部分得分。
|
||||
|
||||
前端处理规则:
|
||||
|
||||
- 重复交卷是幂等的,后端会返回同一份报告。
|
||||
|
||||
@@ -1315,6 +1315,163 @@ async function testCatalogAndLearning() {
|
||||
assert.ok(favoriteReviewSession.item?.questionIds?.includes(ids.questionThree), 'favorite review session should be assembled by backend favorites');
|
||||
}
|
||||
|
||||
async function testCompositePracticeQuestions() {
|
||||
const question = await request('/api/tenant-content/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
questionBankId: ids.questionBank,
|
||||
subjectId: ids.subject,
|
||||
categoryId: ids.category,
|
||||
entryId: ids.contentEntry,
|
||||
contentNodeId: ids.contentNodeSchoolTarget,
|
||||
primaryCollectionId: ids.questionCollection,
|
||||
legacyId: `integration-composite-${Date.now()}`,
|
||||
type: 'reading',
|
||||
typeLabel: '阅读理解',
|
||||
difficulty: 2,
|
||||
content: '阅读材料:Supabase SaaS 题库需要后端统一校验租户、权限和权益。',
|
||||
options: [],
|
||||
correctOptionIndices: [],
|
||||
answerText: null,
|
||||
explanation: '复合题解析应在报告中保留子题明细。',
|
||||
subQuestions: [
|
||||
{
|
||||
id: 'main-idea',
|
||||
type: 'choice',
|
||||
typeLabel: '单选题',
|
||||
content: '材料强调题库权限应由谁统一校验?',
|
||||
options: ['前端页面', '后端服务', '浏览器缓存'],
|
||||
correctOptionIndices: [1],
|
||||
explanation: '租户隔离、权限和权益必须由后端统一校验。',
|
||||
},
|
||||
{
|
||||
id: 'reason',
|
||||
type: 'short_answer',
|
||||
typeLabel: '简答题',
|
||||
content: '简述为什么复合题需要保存每个子题的结构化答案。',
|
||||
answerText: '为了支持断点续练、逐小题复盘和统计分析。',
|
||||
explanation: '结构化子题结果可以服务报告、错题和后续数据分析。',
|
||||
},
|
||||
],
|
||||
status: 'published',
|
||||
},
|
||||
});
|
||||
assert.ok(question.item?.id, 'tenant admin should create a composite reading question');
|
||||
|
||||
const collection = await request('/api/tenant-content/question-collections', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
entryId: ids.contentEntry,
|
||||
nodeId: ids.contentNodeSchoolTarget,
|
||||
regionId: ids.region,
|
||||
subjectId: ids.subject,
|
||||
categoryId: ids.category,
|
||||
questionBankId: ids.questionBank,
|
||||
name: `集成测试复合题集合 ${Date.now()}`,
|
||||
collectionType: 'manual',
|
||||
sourceType: 'manual_questions',
|
||||
totalScore: 10,
|
||||
questions: [{ questionId: question.item.id, sectionKey: 'reading', order: 1, score: 10 }],
|
||||
},
|
||||
});
|
||||
assert.equal(collection.item?.questionCount, 1, 'composite collection should contain the reading question');
|
||||
|
||||
const session = await request('/api/learning/practice-sessions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
collectionId: collection.item.id,
|
||||
mode: 'sequential',
|
||||
questionLimit: 1,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(session.item?.questionIds, [question.item.id], 'composite practice session should snapshot the reading question');
|
||||
|
||||
const topLevelRejected = await request('/api/learning/answers', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
practiceSessionId: session.item.id,
|
||||
questionId: question.item.id,
|
||||
answerText: '不能用顶层答案提交复合题',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(topLevelRejected.code, 'SUB_ANSWERS_REQUIRED', 'composite question should require subAnswers');
|
||||
|
||||
const objectiveSelfJudged = await request('/api/learning/answers', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
practiceSessionId: session.item.id,
|
||||
questionId: question.item.id,
|
||||
subAnswers: [
|
||||
{ subQuestionId: 'main-idea', selectedOptions: ['1'], selfJudgedCorrect: true },
|
||||
],
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(objectiveSelfJudged.code, 'SELF_JUDGMENT_NOT_ALLOWED', 'objective sub questions must not accept self judgment');
|
||||
|
||||
const answer = await request('/api/learning/answers', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
practiceSessionId: session.item.id,
|
||||
questionId: question.item.id,
|
||||
subAnswers: [
|
||||
{ subQuestionId: 'main-idea', selectedOptions: ['1'] },
|
||||
{ subQuestionId: 'reason', answerText: '已按参考答案完成自评', selfJudgedCorrect: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(answer.item?.isCorrect, true, 'composite answer should be correct when all sub questions are correct');
|
||||
assert.equal(answer.item?.compositeSummary?.correctCount, 2, 'composite answer should return sub question summary');
|
||||
assert.ok(answer.item?.subResults?.some(item => item.subQuestionId === 'main-idea' && item.isCorrect === true), 'composite answer should include objective sub result');
|
||||
assert.ok(answer.item?.subResults?.some(item => item.subQuestionId === 'reason' && item.selfJudged === true), 'composite answer should include subjective self judgment');
|
||||
|
||||
const detail = await request('/api/learning/practice-sessions/detail', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { practiceSessionId: session.item.id },
|
||||
});
|
||||
const storedAnswer = detail.item?.answersByQuestion?.[question.item.id];
|
||||
assert.equal(storedAnswer?.answerPayload?.summary?.correctCount, 2, 'session detail should expose composite answer payload for resume');
|
||||
const storedPayloadText = JSON.stringify(storedAnswer?.answerPayload || {});
|
||||
assert.ok(!storedPayloadText.includes('correctAnswerText'), 'stored composite payload must not persist correct answers');
|
||||
assert.ok(!storedPayloadText.includes('explanation'), 'stored composite payload must not persist explanations');
|
||||
assert.ok(!storedPayloadText.includes('为了支持断点续练'), 'stored composite payload must not persist subjective reference answer');
|
||||
assert.equal(detail.item?.questions?.[0]?.subQuestions?.length, 2, 'session detail should include composite sub questions');
|
||||
|
||||
const report = await request('/api/learning/practice-sessions/submit', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
practiceSessionId: session.item.id,
|
||||
},
|
||||
});
|
||||
assert.equal(report.item?.totalQuestions, 1, 'composite report should still count one top-level question');
|
||||
assert.equal(report.item?.correctCount, 1, 'composite report should mark the top-level question correct when all sub questions are correct');
|
||||
assert.equal(report.item?.score, 10, 'composite report should scale sub question score to collection item score');
|
||||
const result = report.item?.questionResults?.find(item => item.questionId === question.item.id);
|
||||
assert.equal(result?.subResults?.length, 2, 'composite report should include per-sub-question results');
|
||||
assert.equal(result?.subResults?.[0]?.totalScore, 5, 'composite report should split top-level score across sub questions by default');
|
||||
assert.ok(
|
||||
result?.subResults?.some(item => item.subQuestionId === 'reason' && item.correctAnswerText === '为了支持断点续练、逐小题复盘和统计分析。'),
|
||||
'composite report should enrich reference answers from the current question version',
|
||||
);
|
||||
assert.ok(
|
||||
result?.subResults?.some(item => item.subQuestionId === 'main-idea' && item.explanation === '租户隔离、权限和权益必须由后端统一校验。'),
|
||||
'composite report should enrich explanations from the current question version',
|
||||
);
|
||||
}
|
||||
|
||||
async function testProfile() {
|
||||
const payload = await request('/api/profile/me');
|
||||
assert.equal(payload.item?.userId, USER_ID, 'profile should belong to smoke user');
|
||||
@@ -6084,6 +6241,7 @@ async function main() {
|
||||
await check('Supabase JWT identity', testSupabaseJwtIdentity);
|
||||
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
|
||||
await check('catalog and learning', testCatalogAndLearning);
|
||||
await check('composite practice questions', testCompositePracticeQuestions);
|
||||
await check('profile', testProfile);
|
||||
await check('learning leaderboard', testLearningLeaderboard);
|
||||
await check('scoreline', testScoreline);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
alter table public.answer_records
|
||||
add column if not exists answer_payload jsonb not null default '{}'::jsonb;
|
||||
|
||||
create index if not exists idx_answer_records_answer_payload_gin
|
||||
on public.answer_records using gin (answer_payload)
|
||||
where answer_payload <> '{}'::jsonb;
|
||||
Reference in New Issue
Block a user