forked from wangziqi/gongxue-base
feat: add practice session resume
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -54,6 +54,16 @@ interface PracticeSessionReportSessionRow {
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>) {
|
||||
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<PracticeSessionDetailRow & Record<string, unknown>>(
|
||||
`
|
||||
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<Record<string, unknown> & { id: string }>).map(item => [item.id, item]));
|
||||
const orderedQuestions = questionIds.map(id => questionById.get(id)).filter(Boolean);
|
||||
const answersByQuestion = Object.fromEntries(
|
||||
(answers as Array<Record<string, unknown> & { 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);
|
||||
|
||||
@@ -43,7 +43,7 @@ pages/student/assets/index 资料列表、预览签名、下载签名
|
||||
pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章
|
||||
```
|
||||
|
||||
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、断点本地恢复、模拟倒计时、主观题后端自评、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善阅读理解/案例分析多小题、长题干排版、公式图片混排、支付容器体验和小程序兼容。
|
||||
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善阅读理解/案例分析多小题、长题干排版、公式图片混排、支付容器体验和小程序兼容。
|
||||
|
||||
学生端商城链路的安全边界:
|
||||
|
||||
|
||||
@@ -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<string, AnswerResult>): Record<string, AnswerState> {
|
||||
const result: Record<string, AnswerState> = {};
|
||||
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<number>(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;
|
||||
|
||||
@@ -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<PracticeReport | null>(null);
|
||||
const [reports, setReports] = useState<PracticeReport[]>([]);
|
||||
const [activeSessions, setActiveSessions] = useState<PracticeHistoryItem[]>([]);
|
||||
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() {
|
||||
</View>
|
||||
) : <View className='empty-state'>暂无练习报告,完成一次练习后会在这里展示。</View>}
|
||||
|
||||
{!practiceSessionId && activeSessions.length ? (
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>继续练习</Text>
|
||||
<View className='list-stack'>
|
||||
{activeSessions.map(item => (
|
||||
<View className='list-row' key={item.id}>
|
||||
<Text className='row-main'>{item.collectionName || item.contentNodeName || item.entryName || item.mode}</Text>
|
||||
<Text className='row-meta'>已答 {String(item.answeredCount || 0)} / {String(item.questionCount || 0)} · {item.startedAt ? item.startedAt.slice(0, 10) : ''}</Text>
|
||||
<View className='toolbar'>
|
||||
<Button className='primary-button' onClick={() => Taro.navigateTo({ url: `/pages/student/practice/index?practiceSessionId=${item.id}` })}>继续</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{current?.sectionStats?.length ? (
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>分段统计</Text>
|
||||
|
||||
@@ -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<string, AnswerResult>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user