From b96897b2beaa7600dbe6f0050ce26223f4c3a845 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 15:12:35 +0800 Subject: [PATCH] feat: improve student practice experience --- apps/api/src/features/learning/routes.ts | 36 ++- apps/taro/README.md | 6 +- .../taro/src/pages/student/practice/index.tsx | 279 +++++++++++++++--- apps/taro/src/pages/student/student.css | 36 +++ apps/taro/src/services/learning.ts | 7 + docs/refactor/backend-handoff-roadmap.md | 6 +- docs/refactor/frontend-handoff-index.md | 6 +- docs/refactor/legacy-feature-gap-matrix.md | 6 +- docs/refactor/next-development-todo.md | 6 +- docs/refactor/taro-frontend-integration.md | 63 ++++ scripts/api-integration-test.js | 39 ++- scripts/smoke-seed.js | 16 +- 12 files changed, 443 insertions(+), 63 deletions(-) diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 26c558e2..2330f135 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -226,6 +226,33 @@ function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerTe return null; } +function hasObjectiveAnswer(row: QuestionAnswerRow) { + return normalizeNumberArray(row.correct_option_indices).length > 0 || + (row.correct_option_index !== null && row.correct_option_index !== undefined); +} + +function optionalBodyBoolean(body: Record, key: string): boolean | undefined { + const value = body[key]; + if (value === undefined || value === null) return undefined; + if (typeof value === 'boolean') return value; + throw new HttpError(400, `${key} must be boolean`, 'INVALID_BOOLEAN_FIELD'); +} + +function resolveJudgedAnswer( + row: QuestionAnswerRow, + selectedOptions: string[], + answerText: string, + selfJudgedCorrect: boolean | undefined, +) { + if (selfJudgedCorrect !== undefined) { + if (hasObjectiveAnswer(row)) { + throw new HttpError(400, 'Self judgment is only allowed for subjective questions', 'SELF_JUDGMENT_NOT_ALLOWED'); + } + return selfJudgedCorrect; + } + return judgeAnswer(row, selectedOptions, answerText); +} + function optionalBodyString(body: Record, key: string) { const value = body[key]; return typeof value === 'string' && value.trim() ? value.trim() : null; @@ -699,6 +726,7 @@ export async function submitAnswerRoute(ctx: RequestContext) { const questionId = requiredString(body, 'questionId'); const selectedOptions = optionalStringArray(body, 'selectedOptions'); const answerText = optionalString(body, 'answerText'); + const selfJudgedCorrect = optionalBodyBoolean(body, 'selfJudgedCorrect'); const practiceSessionId = optionalString(body, 'practiceSessionId') || null; const question = await queryOne( @@ -717,10 +745,9 @@ export async function submitAnswerRoute(ctx: RequestContext) { throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND'); } - const judged = judgeAnswer(question, selectedOptions, answerText); - const result = await transaction(async client => { await assertAnswerSessionAccess(client, { tenantId, userId, practiceSessionId, questionId }); + const judged = resolveJudgedAnswer(question, selectedOptions, answerText, selfJudgedCorrect); const answerResult = await client.query( ` @@ -774,7 +801,10 @@ export async function submitAnswerRoute(ctx: RequestContext) { [tenantId, userId, judged], ); - return answerResult.rows[0]; + return { + ...answerResult.rows[0], + selfJudged: selfJudgedCorrect !== undefined, + }; }); return { item: result }; diff --git a/apps/taro/README.md b/apps/taro/README.md index 94e73c6e..bcec0e4b 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -30,7 +30,7 @@ pages/student/login/index 短信登录 pages/student/home/index 首页与功能入口 pages/student/region/index 地区选择、目标地区保存 pages/student/catalog/index 题库入口、分类、集合、练习蓝图 -pages/student/practice/index 创建练习 session、答题、收藏、反馈、视频入口、交卷报告 +pages/student/practice/index 创建练习 session、答题卡、进度恢复、倒计时、客观题判分、主观题自评、收藏、反馈、视频入口、交卷报告 pages/student/review/index 错题本、收藏夹、错题/收藏复习 pages/student/reports/index 练习报告、模考报告、历史报告 pages/student/video/index 题目视频解析、播放签名 @@ -43,7 +43,7 @@ pages/student/assets/index 资料列表、预览签名、下载签名 pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章 ``` -这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善刷题细节、支付容器体验和小程序兼容。 +这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、断点本地恢复、模拟倒计时、主观题后端自评、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善阅读理解/案例分析多小题、长题干排版、公式图片混排、支付容器体验和小程序兼容。 学生端商城链路的安全边界: @@ -101,7 +101,7 @@ TARO_APP_TENANT_CODE=<可选,小程序/预览环境使用> - 业务数据默认走 `apps/api`。 - `x-tenant-id` 只是租户上下文,不是身份凭证。 - 登录后不要传 `x-user-id` 或 body/query `userId` 伪造当前用户。 -- 题库练习、订单支付、内容导入、CRM、资料签名、后台配置必须走后端命令层。 +- 题库练习、答案判分、主观题自评、订单支付、内容导入、CRM、资料签名、后台配置必须走后端命令层。 ## H5 部署建议 diff --git a/apps/taro/src/pages/student/practice/index.tsx b/apps/taro/src/pages/student/practice/index.tsx index 4fd41154..d7337ff9 100644 --- a/apps/taro/src/pages/student/practice/index.tsx +++ b/apps/taro/src/pages/student/practice/index.tsx @@ -7,13 +7,23 @@ import { submitAnswer, submitPracticeSession, toggleQuestionFavorite, + type AnswerResult, type PracticeReport, type PracticeSession, type QuestionItem, } from '@/services/learning'; import { submitFeedback } from '@/services/profile'; +import { getStorage, setStorage } from '@/services/storage'; import '../student.css'; +type AnswerState = { + selectedOptions: string[]; + answerText: string; + isCorrect: boolean | null; + answeredAt?: string; + selfJudged?: boolean; +}; + function plainOption(option: unknown, index: number) { if (typeof option === 'string') return option; if (option && typeof option === 'object') { @@ -23,6 +33,39 @@ function plainOption(option: unknown, index: number) { return `选项 ${index + 1}`; } +function isObjectiveQuestion(question?: QuestionItem | null) { + if (!question) return false; + return Array.isArray(question.options) && question.options.length > 0; +} + +function isMultiQuestion(question?: QuestionItem | null) { + if (!question) return false; + const type = String(question.type || '').toLowerCase(); + return type.includes('multi') || (question.correctOptionIndices?.length || 0) > 1; +} + +function answerStorageKey(sessionId: string) { + return `tiku:practice:${sessionId}:answers`; +} + +function indexStorageKey(sessionId: string) { + return `tiku:practice:${sessionId}:index`; +} + +function formatDuration(seconds: number) { + const safeSeconds = Math.max(0, Math.trunc(seconds)); + const minutes = Math.floor(safeSeconds / 60); + const rest = safeSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`; +} + +function answerLabel(state?: AnswerState) { + if (!state) return '未答'; + if (state.isCorrect === true) return '正确'; + if (state.isCorrect === false) return '错误'; + return state.selfJudged ? '已自评' : '已答'; +} + export default function StudentPracticePage() { const router = useRouter(); const params = router.params || {}; @@ -32,7 +75,12 @@ export default function StudentPracticePage() { const [selected, setSelected] = useState([]); const [answerText, setAnswerText] = useState(''); const [feedbackText, setFeedbackText] = useState(''); - const [resultByQuestion, setResultByQuestion] = useState>({}); + const [answerByQuestion, setAnswerByQuestion] = useState>({}); + const [favoriteByQuestion, setFavoriteByQuestion] = useState>({}); + const [showExplanation, setShowExplanation] = useState(false); + const [completed, setCompleted] = useState(false); + const [timeLeft, setTimeLeft] = useState(null); + const [loading, setLoading] = useState(true); const [report, setReport] = useState(null); const [error, setError] = useState(''); @@ -50,6 +98,11 @@ export default function StudentPracticePage() { .then(async payload => { const nextSession = payload.item; setSession(nextSession); + if (nextSession.durationMinutes) setTimeLeft(nextSession.durationMinutes * 60); + const savedIndex = getStorage(indexStorageKey(nextSession.id)); + const savedAnswers = getStorage>(answerStorageKey(nextSession.id)); + if (savedAnswers) setAnswerByQuestion(savedAnswers); + if (typeof savedIndex === 'number') setIndex(savedIndex); const questionPayload = collectionId ? await loadCollectionQuestions(collectionId, 300) : await loadQuestions({ @@ -62,43 +115,133 @@ export default function StudentPracticePage() { const ordered = (nextSession.questionIds || []).map(id => byId.get(id)).filter(Boolean) as QuestionItem[]; setQuestions(ordered.length ? ordered : questionPayload.items || []); }) - .catch(nextError => setError(nextError instanceof Error ? nextError.message : '练习创建失败')); + .catch(nextError => setError(nextError instanceof Error ? nextError.message : '练习创建失败')) + .finally(() => setLoading(false)); }, []); const current = questions[index]; const options = useMemo(() => Array.isArray(current?.options) ? current.options : [], [current]); - const isSubmitted = current ? current.id in resultByQuestion : false; + const answerState = current ? answerByQuestion[current.id] : undefined; + const isSubmitted = !!answerState; + const answeredCount = Object.keys(answerByQuestion).length; + const correctCount = Object.values(answerByQuestion).filter(item => item.isCorrect === true).length; + const wrongCount = Object.values(answerByQuestion).filter(item => item.isCorrect === false).length; + const progressPercent = questions.length ? Math.round((answeredCount / questions.length) * 100) : 0; - function toggleOption(optionIndex: number) { - const value = String(optionIndex); - setSelected(prev => prev.includes(value) ? prev.filter(item => item !== value) : [...prev, value]); + useEffect(() => { + if (!session) return; + setStorage(indexStorageKey(session.id), index); + }, [index, session?.id]); + + useEffect(() => { + if (!session) return; + setStorage(answerStorageKey(session.id), answerByQuestion); + }, [answerByQuestion, session?.id]); + + useEffect(() => { + if (!current) return; + const state = answerByQuestion[current.id]; + setSelected(state?.selectedOptions || []); + setAnswerText(state?.answerText || ''); + setFeedbackText(''); + setShowExplanation(!!state); + }, [current?.id]); + + useEffect(() => { + if (timeLeft === null || report || completed) return; + if (timeLeft <= 0) { + void handleSubmitSession(true); + return; + } + const timer = setTimeout(() => setTimeLeft(prev => (prev === null ? null : Math.max(0, prev - 1))), 1000); + return () => clearTimeout(timer); + }, [timeLeft, report, completed]); + + function rememberAnswer( + questionId: string, + result: AnswerResult | { isCorrect: boolean | null; answeredAt?: string; selfJudged?: boolean }, + nextSelected = selected, + nextAnswerText = answerText, + ) { + const nextState: AnswerState = { + selectedOptions: [...nextSelected], + answerText: nextAnswerText, + isCorrect: result.isCorrect, + answeredAt: 'answeredAt' in result ? result.answeredAt : undefined, + selfJudged: 'selfJudged' in result ? result.selfJudged : undefined, + }; + setAnswerByQuestion(prev => ({ ...prev, [questionId]: nextState })); + setShowExplanation(true); } - async function handleSubmit() { + function toggleOption(optionIndex: number) { + if (isSubmitted) return; + const value = String(optionIndex); + const nextSelected = selected.includes(value) ? selected.filter(item => item !== value) : [...selected, value]; + setSelected(nextSelected); + if (current && !isMultiQuestion(current) && nextSelected.length === 1) { + void handleSubmit(nextSelected); + } + } + + async function handleSubmit(nextSelected = selected, selfCorrect?: boolean) { if (!session || !current) return; + if (!isObjectiveQuestion(current) && selfCorrect !== undefined) { + try { + const payload = await submitAnswer({ + practiceSessionId: session.id, + questionId: current.id, + selectedOptions: [], + answerText, + selfJudgedCorrect: selfCorrect, + }); + rememberAnswer(current.id, payload.item); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '提交失败'); + } + return; + } try { const payload = await submitAnswer({ practiceSessionId: session.id, questionId: current.id, - selectedOptions: selected, + selectedOptions: nextSelected, answerText, }); - setResultByQuestion(prev => ({ ...prev, [current.id]: payload.item.isCorrect })); + rememberAnswer(current.id, payload.item, nextSelected); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '提交失败'); } } + function previousQuestion() { + setIndex(prev => Math.max(0, prev - 1)); + setCompleted(false); + } + function nextQuestion() { - setSelected([]); - setAnswerText(''); - setFeedbackText(''); + if (index >= questions.length - 1) { + setCompleted(true); + return; + } setIndex(prev => Math.min(questions.length - 1, prev + 1)); + setCompleted(false); + } + + function jumpQuestion(nextIndex: number) { + setIndex(Math.max(0, Math.min(questions.length - 1, nextIndex))); + setCompleted(false); } async function handleFavorite() { if (!current) return; - await toggleQuestionFavorite(current.id, true).catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败')); + const nextFavorite = !favoriteByQuestion[current.id]; + await toggleQuestionFavorite(current.id, nextFavorite) + .then(payload => { + setFavoriteByQuestion(prev => ({ ...prev, [current.id]: payload.favorite })); + Taro.showToast({ title: payload.favorite ? '已收藏' : '已取消', icon: 'success' }); + }) + .catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败')); } async function handleFeedback() { @@ -121,18 +264,21 @@ export default function StudentPracticePage() { } } - async function handleSubmitSession() { + async function handleSubmitSession(auto = false) { if (!session) return; - const ok = await Taro.showModal({ - title: '交卷', - content: '确认交卷并生成练习报告?报告会按后端 session 快照评分。', - confirmText: '交卷', - cancelText: '取消', - }); - if (!ok.confirm) return; + if (!auto) { + const ok = await Taro.showModal({ + title: '交卷', + content: `已答 ${answeredCount}/${questions.length},确认交卷并生成练习报告?`, + confirmText: '交卷', + cancelText: '取消', + }); + if (!ok.confirm) return; + } try { const payload = await submitPracticeSession(session.id); setReport(payload.item || null); + setCompleted(true); Taro.showToast({ title: '报告已生成', icon: 'success' }); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '交卷失败'); @@ -157,8 +303,20 @@ export default function StudentPracticePage() { {session?.mode || '练习'} 题目来自后端 session 快照,答题、交卷、错题和报告都以后端校验为准。 + {timeLeft !== null ? {formatDuration(timeLeft)} : null} + {session ? ( + + + 进度 {answeredCount}/{questions.length} + {progressPercent}% + + 正确 {correctCount} · 错误 {wrongCount} · 未答 {Math.max(0, questions.length - answeredCount)} · {session.accessMode || 'access'} + {session.consumedFreeQuota ? 本次消耗免费题量 {session.consumedFreeQuota} : null} + + ) : null} + {report ? ( {String(report.score)} / {String(report.totalScore)} @@ -169,31 +327,65 @@ export default function StudentPracticePage() { ) : null} - {current ? ( + {completed && !report ? ( + + 已完成本组练习 + 已答 {answeredCount}/{questions.length} · 正确 {correctCount} · 错误 {wrongCount} + + + + + + + ) : null} + + {loading ? 练习加载中... : null} + + {current && !completed ? ( - {current.typeLabel || current.type || '题目'} + + {current.typeLabel || current.type || '题目'} + {answerLabel(answerState)} + {current.content || '未提供题干'} + {current.mediaUrl ? {current.mediaUrl} : null} + {current.subQuestions?.length ? 包含 {current.subQuestions.length} 个子题,请结合题干作答。 : null} {options.length ? options.map((option, optionIndex) => ( - toggleOption(optionIndex)}> - {selected.includes(String(optionIndex)) ? '已选 · ' : ''}{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)} + toggleOption(optionIndex)} + > + {String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)} )) : ( -