diff --git a/apps/api/src/features/learning/index.ts b/apps/api/src/features/learning/index.ts index 5224adae..47e7bcb9 100644 --- a/apps/api/src/features/learning/index.ts +++ b/apps/api/src/features/learning/index.ts @@ -6,6 +6,7 @@ import { favoriteWordsRoute, learningStatsRoute, learningTrendRoute, + practiceSessionDetailRoute, practiceReportsRoute, practiceHistoryRoute, practiceSessionReportRoute, @@ -26,6 +27,7 @@ import { export const learningRoutes: RouteDefinition[] = [ ['GET', '/api/learning/leaderboard', learningLeaderboardRoute], ['POST', '/api/learning/practice-sessions', createPracticeSessionRoute], + ['GET', '/api/learning/practice-sessions/detail', practiceSessionDetailRoute], ['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute], ['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute], ['GET', '/api/learning/practice-sessions/history', practiceHistoryRoute], diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 2330f135..0cd6861d 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -54,6 +54,16 @@ interface PracticeSessionReportSessionRow { metadata: Record; } +interface PracticeSessionDetailRow extends PracticeSessionReportSessionRow { + targetType: string | null; + targetId: string | null; + accessMode: string | null; + accessEntitlementId: string | null; + consumedFreeQuota: number; + accessSnapshot: unknown; + expiresAt: string | null; +} + interface PracticeSessionReportRow { id: string; tenantId: string; @@ -829,6 +839,27 @@ function formatReport(row: PracticeSessionReportRow) { }; } +function practiceSessionStatus(row: { finishedAt?: string | null; expiresAt?: string | null }) { + if (row.finishedAt) return 'finished'; + if (row.expiresAt && new Date(row.expiresAt).getTime() <= Date.now()) return 'expired'; + return 'active'; +} + +function formatPracticeSession(row: PracticeSessionDetailRow & Record) { + const questionIds = idArrayFromJson(row.questionIds); + return { + ...row, + questionIds, + questionCount: Number(row.questionCount || questionIds.length), + durationMinutes: row.durationMinutes === null || row.durationMinutes === undefined ? null : Number(row.durationMinutes), + totalScore: row.totalScore === null || row.totalScore === undefined ? null : finiteNumber(row.totalScore, 0), + consumedFreeQuota: row.consumedFreeQuota === undefined ? undefined : Number(row.consumedFreeQuota || 0), + accessSnapshot: jsonObject(row.accessSnapshot), + metadata: jsonObject(row.metadata), + status: practiceSessionStatus(row), + }; +} + async function fetchPracticeReportBySession( client: { query: pg.PoolClient['query'] }, tenantId: string, @@ -1187,6 +1218,93 @@ export async function practiceSessionReportRoute(ctx: RequestContext) { return { item: formatReport(report) }; } +export async function practiceSessionDetailRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const practiceSessionId = stringParam(ctx, 'practiceSessionId'); + if (!practiceSessionId) { + throw new HttpError(400, 'practiceSessionId is required', 'PRACTICE_SESSION_ID_REQUIRED'); + } + + const session = await queryOne>( + ` + select ps.id, ps.tenant_id as "tenantId", ps.user_id as "userId", ps.mode, + ps.target_type as "targetType", ps.target_id as "targetId", + ps.blueprint_id as "blueprintId", ps.collection_id as "collectionId", + ps.entry_id as "entryId", ps.content_node_id as "contentNodeId", + ps.question_ids as "questionIds", ps.question_count as "questionCount", + ps.duration_minutes as "durationMinutes", ps.total_score as "totalScore", + ps.access_mode as "accessMode", ps.access_entitlement_id as "accessEntitlementId", + ps.consumed_free_quota as "consumedFreeQuota", ps.access_snapshot as "accessSnapshot", + ps.started_at as "startedAt", ps.finished_at as "finishedAt", + ps.expires_at as "expiresAt", ps.metadata + from public.practice_sessions ps + where ps.tenant_id = $1 and ps.user_id = $2 and ps.id = $3 + limit 1 + `, + [tenantId, userId, practiceSessionId], + ); + if (!session) { + throw new HttpError(404, 'Practice session not found', 'PRACTICE_SESSION_NOT_FOUND'); + } + + const questionIds = idArrayFromJson(session.questionIds); + const [questions, answers] = await Promise.all([ + questionIds.length ? query( + ` + select q.id, q.legacy_id as "legacyId", q.entry_id as "entryId", + q.content_node_id as "contentNodeId", + q.primary_collection_id as "primaryCollectionId", + q.subject_id as "subjectId", q.category_id as "categoryId", q.node_id as "nodeId", + q.type, q.type_label as "typeLabel", q.difficulty, q.tags, + q.exam_markers as "examMarkers", + q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation", + v.id as "versionId", v.content, v.options, + v.correct_option_index as "correctOptionIndex", + v.correct_option_indices as "correctOptionIndices", + v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions", + v.code_lang as "codeLang", v.code_template as "codeTemplate", + q.created_at as "createdAt", q.updated_at as "updatedAt" + 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 = any($2::uuid[]) + `, + [tenantId, questionIds], + ) : Promise.resolve([]), + questionIds.length ? query( + ` + 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" + from public.answer_records + where tenant_id = $1 + and user_id = $2 + and practice_session_id = $3 + and question_id = any($4::uuid[]) + order by question_id, answered_at desc + `, + [tenantId, userId, practiceSessionId, questionIds], + ) : Promise.resolve([]), + ]); + const questionById = new Map((questions as Array & { id: string }>).map(item => [item.id, item])); + const orderedQuestions = questionIds.map(id => questionById.get(id)).filter(Boolean); + const answersByQuestion = Object.fromEntries( + (answers as Array & { questionId: string }>).map(answer => [answer.questionId, { + ...answer, + selectedOptions: normalizeStringArray(answer.selectedOptions), + }]), + ); + + return { + item: { + ...formatPracticeSession(session), + questions: orderedQuestions, + answersByQuestion, + }, + }; +} + export async function practiceReportsRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); diff --git a/apps/taro/README.md b/apps/taro/README.md index bcec0e4b..fc36b93e 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -43,7 +43,7 @@ pages/student/assets/index 资料列表、预览签名、下载签名 pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章 ``` -这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、断点本地恢复、模拟倒计时、主观题后端自评、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善阅读理解/案例分析多小题、长题干排版、公式图片混排、支付容器体验和小程序兼容。 +这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善阅读理解/案例分析多小题、长题干排版、公式图片混排、支付容器体验和小程序兼容。 学生端商城链路的安全边界: diff --git a/apps/taro/src/pages/student/practice/index.tsx b/apps/taro/src/pages/student/practice/index.tsx index d7337ff9..5340603c 100644 --- a/apps/taro/src/pages/student/practice/index.tsx +++ b/apps/taro/src/pages/student/practice/index.tsx @@ -4,6 +4,7 @@ import { Button, Text, Textarea, View } from '@tarojs/components'; import { loadCollectionQuestions, loadQuestions } from '@/services/catalog'; import { createPracticeSession, + loadPracticeSessionDetail, submitAnswer, submitPracticeSession, toggleQuestionFavorite, @@ -66,6 +67,27 @@ function answerLabel(state?: AnswerState) { return state.selfJudged ? '已自评' : '已答'; } +function answersFromBackend(answers?: Record): Record { + const result: Record = {}; + for (const [questionId, answer] of Object.entries(answers || {})) { + result[questionId] = { + selectedOptions: Array.isArray(answer.selectedOptions) ? answer.selectedOptions : [], + answerText: answer.answerText || '', + isCorrect: answer.isCorrect, + answeredAt: answer.answeredAt, + selfJudged: answer.selfJudged, + }; + } + return result; +} + +function secondsUntil(value?: string | null) { + if (!value) return null; + const parsed = new Date(value).getTime(); + if (!Number.isFinite(parsed)) return null; + return Math.max(0, Math.ceil((parsed - Date.now()) / 1000)); +} + export default function StudentPracticePage() { const router = useRouter(); const params = router.params || {}; @@ -85,6 +107,7 @@ export default function StudentPracticePage() { const [error, setError] = useState(''); useEffect(() => { + const practiceSessionId = params.practiceSessionId || undefined; const collectionId = params.collectionId || undefined; const body = { blueprintId: params.blueprintId || undefined, @@ -94,6 +117,27 @@ export default function StudentPracticePage() { mode: params.mode || 'sequential', questionLimit: 50, }; + + if (practiceSessionId) { + loadPracticeSessionDetail(practiceSessionId) + .then(payload => { + const nextSession = payload.item; + const backendAnswers = answersFromBackend(nextSession.answersByQuestion); + setSession(nextSession); + setQuestions(nextSession.questions || []); + setAnswerByQuestion(backendAnswers); + const savedIndex = getStorage(indexStorageKey(nextSession.id)); + const firstUnanswered = (nextSession.questionIds || []).findIndex(questionId => !backendAnswers[questionId]); + setIndex(typeof savedIndex === 'number' ? savedIndex : Math.max(0, firstUnanswered)); + const remaining = secondsUntil(nextSession.expiresAt); + if (remaining !== null) setTimeLeft(remaining); + if (nextSession.status === 'finished') setCompleted(true); + }) + .catch(nextError => setError(nextError instanceof Error ? nextError.message : '练习恢复失败')) + .finally(() => setLoading(false)); + return; + } + createPracticeSession(body) .then(async payload => { const nextSession = payload.item; diff --git a/apps/taro/src/pages/student/reports/index.tsx b/apps/taro/src/pages/student/reports/index.tsx index 08d866d9..93847a5c 100644 --- a/apps/taro/src/pages/student/reports/index.tsx +++ b/apps/taro/src/pages/student/reports/index.tsx @@ -1,7 +1,13 @@ import { useEffect, useState } from 'react'; import Taro, { useRouter } from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; -import { loadPracticeReports, loadPracticeSessionReport, type PracticeReport } from '@/services/learning'; +import { + loadPracticeHistory, + loadPracticeReports, + loadPracticeSessionReport, + type PracticeHistoryItem, + type PracticeReport, +} from '@/services/learning'; import '../student.css'; function percent(value?: number) { @@ -13,6 +19,7 @@ export default function StudentReportsPage() { const practiceSessionId = router.params?.practiceSessionId || ''; const [report, setReport] = useState(null); const [reports, setReports] = useState([]); + const [activeSessions, setActiveSessions] = useState([]); const [error, setError] = useState(''); useEffect(() => { @@ -22,8 +29,14 @@ export default function StudentReportsPage() { .catch(nextError => setError(nextError instanceof Error ? nextError.message : '报告加载失败')); return; } - loadPracticeReports({ limit: 20 }) - .then(payload => setReports(payload.items || [])) + Promise.all([ + loadPracticeReports({ limit: 20 }), + loadPracticeHistory({ status: 'active', limit: 10 }), + ]) + .then(([reportPayload, historyPayload]) => { + setReports(reportPayload.items || []); + setActiveSessions(historyPayload.items || []); + }) .catch(nextError => setError(nextError instanceof Error ? nextError.message : '报告列表加载失败')); }, [practiceSessionId]); @@ -51,6 +64,23 @@ export default function StudentReportsPage() { ) : 暂无练习报告,完成一次练习后会在这里展示。} + {!practiceSessionId && activeSessions.length ? ( + + 继续练习 + + {activeSessions.map(item => ( + + {item.collectionName || item.contentNodeName || item.entryName || item.mode} + 已答 {String(item.answeredCount || 0)} / {String(item.questionCount || 0)} · {item.startedAt ? item.startedAt.slice(0, 10) : ''} + + + + + ))} + + + ) : null} + {current?.sectionStats?.length ? ( 分段统计 diff --git a/apps/taro/src/services/learning.ts b/apps/taro/src/services/learning.ts index bd174e68..ada9c816 100644 --- a/apps/taro/src/services/learning.ts +++ b/apps/taro/src/services/learning.ts @@ -39,6 +39,18 @@ export interface PracticeSession { expiresAt?: string | null; } +export interface PracticeSessionDetail extends PracticeSession { + status?: string; + startedAt?: string; + finishedAt?: string | null; + entryId?: string | null; + contentNodeId?: string | null; + collectionId?: string | null; + blueprintId?: string | null; + questions?: QuestionItem[]; + answersByQuestion?: Record; +} + export interface AnswerResult { id: string; questionId: string; @@ -129,6 +141,12 @@ export async function createPracticeSession(body: { }); } +export async function loadPracticeSessionDetail(practiceSessionId: string) { + return apiRequest<{ item: PracticeSessionDetail }>('/api/learning/practice-sessions/detail', { + query: { practiceSessionId }, + }); +} + export async function submitAnswer(body: { practiceSessionId: string; questionId: string; diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 9a59439b..f5be5665 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 | ## 前端接入建议 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 3bafcac6..50db747a 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -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`;已接答题卡、本地进度恢复、倒计时、主观题 `selfJudgedCorrect` | +| 练习 | `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/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` | diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 8e9b7236..8f355be7 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -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 已有答题卡、本地进度恢复、客观题自动提交和主观题后端自评第一版;继续补后端权威断点续练、长题干和多小题体验 | +| 顺序刷题 | `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/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 57303fe2..2c7bb950 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -101,7 +101,7 @@ - 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。 - 已完成单词复习算法、每日计划和复习上报。 - 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。 - - Taro 已有本地断点恢复和倒计时第一版;继续补后端权威断点续练、复盘体验和多小题统计口径。 + - 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 迁移。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index be48bc85..57605299 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -153,6 +153,7 @@ tenant::theme | 分类树 | `/api/catalog/content-nodes?entryId=...&parentId=root` | | 题目列表 | `/api/catalog/question-collections`、`/api/catalog/question-collections/questions` | | 开始练习 | `POST /api/learning/practice-sessions` | +| 恢复练习 | `GET /api/learning/practice-sessions/detail?practiceSessionId=...` | | 提交答案 | `POST /api/learning/answers` | | 交卷/报告 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports` | | 错题本 | `GET /api/learning/wrong-questions`、`POST /api/learning/wrong-questions/resolve` | @@ -232,6 +233,44 @@ tenant::theme - `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。 - 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 session 的题目。 +### 断点续练 + +Taro 可以缓存当前题号和答题卡用于刷新恢复体验,但跨设备、清缓存、小程序重启后的权威恢复必须调用: + +```text +GET /api/learning/practice-sessions/detail?practiceSessionId= +``` + +响应会返回: + +```json +{ + "item": { + "id": "...", + "status": "active", + "questionIds": ["..."], + "questions": [], + "answersByQuestion": { + "": { + "selectedOptions": ["1"], + "answerText": null, + "isCorrect": true, + "answeredAt": "..." + } + }, + "expiresAt": "..." + } +} +``` + +前端处理规则: + +- 继续练习入口优先从 `GET /api/learning/practice-sessions/history?status=active` 获取未完成 session,再带 `practiceSessionId` 进入练习页。 +- 练习页如果 URL 有 `practiceSessionId`,先调用 detail 恢复后端题目快照和最新答案,不要新建 session。 +- `answersByQuestion` 是同一题的最新答题记录,答题卡、正确/错误统计和解析展示以它为准。 +- 倒计时以 `expiresAt` 计算剩余时间;不要用本地启动时间重新生成考试时长。 +- detail 只返回当前用户自己的 session;跨用户或跨租户读取会返回 `PRACTICE_SESSION_NOT_FOUND`。 + ## 提交答案契约 客观题、主观题都统一调用: diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 1f64fc10..ba028b41 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -1065,6 +1065,27 @@ async function testCatalogAndLearning() { }); assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false'); + const freeSessionDetail = await request('/api/learning/practice-sessions/detail', { + userId: false, + headers: { authorization: `Bearer ${freeLogin.session.token}` }, + query: { practiceSessionId: freeSession.item.id }, + }); + assert.equal(freeSessionDetail.item?.id, freeSession.item.id, 'session detail should load own active practice session'); + assert.equal(freeSessionDetail.item?.status, 'active', 'session detail should expose active status'); + assert.deepEqual(freeSessionDetail.item?.questionIds, freeSession.item.questionIds, 'session detail should preserve backend question snapshot'); + assert.ok(freeSessionDetail.item?.questions?.some(item => item.id === ids.question), 'session detail should include snapshot questions'); + assert.equal( + freeSessionDetail.item?.answersByQuestion?.[ids.question]?.isCorrect, + false, + 'session detail should include latest answer records for resume', + ); + + const crossUserSessionDetail = await request('/api/learning/practice-sessions/detail', { + query: { practiceSessionId: freeSession.item.id }, + expectStatus: 404, + }); + assert.equal(crossUserSessionDetail.code, 'PRACTICE_SESSION_NOT_FOUND', 'session detail must not expose another user session'); + const selfJudgedObjective = await request('/api/learning/answers', { userId: false, headers: { authorization: `Bearer ${freeLogin.session.token}` }, @@ -1195,6 +1216,13 @@ async function testCatalogAndLearning() { }); assert.equal(fetchedReport.item?.id, mockReport.item.id, 'mock report should be queryable'); + const finishedSessionDetail = await request('/api/learning/practice-sessions/detail', { + userId: TENANT_ADMIN_USER_ID, + query: { practiceSessionId: mockSession.item.id }, + }); + assert.equal(finishedSessionDetail.item?.status, 'finished', 'session detail should expose finished status after submit'); + assert.equal(finishedSessionDetail.item?.answersByQuestion?.[ids.questionThree]?.isCorrect, true, 'finished session detail should include subjective answer'); + const reportList = await request('/api/learning/practice-reports', { userId: TENANT_ADMIN_USER_ID, query: { blueprintId: ids.practiceBlueprintMock },