feat: improve student practice experience

This commit is contained in:
Codex
2026-06-29 15:12:35 +08:00
parent 134f7830dd
commit b96897b2be
12 changed files with 443 additions and 63 deletions

View File

@@ -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 部署建议

View File

@@ -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<string[]>([]);
const [answerText, setAnswerText] = useState('');
const [feedbackText, setFeedbackText] = useState('');
const [resultByQuestion, setResultByQuestion] = useState<Record<string, boolean | null>>({});
const [answerByQuestion, setAnswerByQuestion] = useState<Record<string, AnswerState>>({});
const [favoriteByQuestion, setFavoriteByQuestion] = useState<Record<string, boolean>>({});
const [showExplanation, setShowExplanation] = useState(false);
const [completed, setCompleted] = useState(false);
const [timeLeft, setTimeLeft] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [report, setReport] = useState<PracticeReport | null>(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<number>(indexStorageKey(nextSession.id));
const savedAnswers = getStorage<Record<string, AnswerState>>(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() {
<Text className='student-title'>{session?.mode || '练习'}</Text>
<Text className='student-subtitle'> session </Text>
</View>
{timeLeft !== null ? <Text className='status-badge'>{formatDuration(timeLeft)}</Text> : null}
</View>
{session ? (
<View className='quiet-panel section-block'>
<View className='amount-row'>
<Text className='row-main'> {answeredCount}/{questions.length}</Text>
<Text className='status-badge'>{progressPercent}%</Text>
</View>
<Text className='row-meta'> {correctCount} · {wrongCount} · {Math.max(0, questions.length - answeredCount)} · {session.accessMode || 'access'}</Text>
{session.consumedFreeQuota ? <Text className='row-meta'> {session.consumedFreeQuota}</Text> : null}
</View>
) : null}
{report ? (
<View className='report-panel section-block'>
<Text className='metric-value'>{String(report.score)} / {String(report.totalScore)}</Text>
@@ -169,31 +327,65 @@ export default function StudentPracticePage() {
</View>
) : null}
{current ? (
{completed && !report ? (
<View className='report-panel section-block'>
<Text className='metric-value'></Text>
<Text className='metric-label'> {answeredCount}/{questions.length} · {correctCount} · {wrongCount}</Text>
<View className='toolbar wrap'>
<Button className='primary-button' onClick={() => handleSubmitSession()}></Button>
<Button className='secondary-button' onClick={() => jumpQuestion(0)}></Button>
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/review/index?type=wrong' })}></Button>
</View>
</View>
) : null}
{loading ? <View className='empty-state'>...</View> : null}
{current && !completed ? (
<View className='list-stack'>
<View className='quiet-panel'>
<Text className='row-meta'>{current.typeLabel || current.type || '题目'}</Text>
<View className='amount-row'>
<Text className='row-meta'>{current.typeLabel || current.type || '题目'}</Text>
<Text className='status-badge'>{answerLabel(answerState)}</Text>
</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}
</View>
{options.length ? options.map((option, optionIndex) => (
<View className='list-row' key={String(optionIndex)} onClick={() => toggleOption(optionIndex)}>
<Text className='row-main'>{selected.includes(String(optionIndex)) ? '已选 · ' : ''}{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)}</Text>
<View
className={`list-row ${selected.includes(String(optionIndex)) ? 'active' : ''}`}
key={String(optionIndex)}
onClick={() => toggleOption(optionIndex)}
>
<Text className='row-main'>{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)}</Text>
</View>
)) : (
<Textarea className='textarea' placeholder='请输入答案' value={answerText} onInput={event => setAnswerText(String(event.detail.value || ''))} />
)}
{isSubmitted ? (
<View className='quiet-panel'>
<Text className={resultByQuestion[current.id] ? 'success-text' : 'error-text'}>{resultByQuestion[current.id] ? '回答正确' : '回答错误'}</Text>
<Textarea className='textarea' placeholder='请输入答案或先思考后查看参考答案' value={answerText} onInput={event => setAnswerText(String(event.detail.value || ''))} />
<View className='toolbar wrap'>
<Button className='secondary-button' onClick={() => setShowExplanation(true)}></Button>
<Button className='primary-button' onClick={() => handleSubmit(selected, true)}></Button>
<Button className='danger-button' onClick={() => handleSubmit(selected, false)}></Button>
</View>
</View>
)}
{showExplanation || isSubmitted ? (
<View className='quiet-panel'>
{isSubmitted ? <Text className={answerState?.isCorrect ? 'success-text' : answerState?.isCorrect === false ? 'error-text' : 'row-meta'}>{answerLabel(answerState)}</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}
{current.explanation ? <Text className='row-meta'>{current.explanation}</Text> : null}
</View>
) : null}
<View className='toolbar'>
<Button className='primary-button' onClick={handleSubmit}></Button>
<Button className='secondary-button' onClick={handleFavorite}></Button>
<Button className='secondary-button' onClick={openVideo}></Button>
<View className='toolbar wrap'>
{isObjectiveQuestion(current) ? <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}
<Button className='secondary-button' onClick={nextQuestion}></Button>
<Button className='secondary-button' onClick={handleSubmitSession}></Button>
<Button className='secondary-button' onClick={() => handleSubmitSession()}></Button>
</View>
<View className='quiet-panel'>
<Text className='row-main'></Text>
@@ -203,7 +395,24 @@ export default function StudentPracticePage() {
</View>
</View>
</View>
) : <View className='empty-state'></View>}
) : !loading && !completed ? <View className='empty-state'></View> : null}
{questions.length ? (
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='answer-sheet'>
{questions.map((item, cardIndex) => (
<Button
key={item.id}
className={`answer-card ${cardIndex === index ? 'current' : ''} ${answerByQuestion[item.id] ? 'answered' : ''} ${answerByQuestion[item.id]?.isCorrect === false ? 'wrong' : ''}`}
onClick={() => jumpQuestion(cardIndex)}
>
{cardIndex + 1}
</Button>
))}
</View>
</View>
) : null}
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
);

View File

@@ -286,6 +286,42 @@
font-weight: 750;
}
.answer-sheet {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.answer-card {
min-width: 0;
height: 58px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #334155;
font-size: 22px;
font-weight: 800;
line-height: 58px;
}
.answer-card.current {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.answer-card.answered {
border-color: #bbf7d0;
background: #ecfdf5;
color: #047857;
}
.answer-card.wrong {
border-color: #fecaca;
background: #fff1f2;
color: #be123c;
}
.break-text {
word-break: break-all;
overflow-wrap: anywhere;

View File

@@ -14,11 +14,13 @@ export interface QuestionItem {
content?: string;
type?: string;
typeLabel?: string;
mediaUrl?: string | null;
options?: QuestionOption[] | string[] | null;
answerText?: string | null;
correctOptionIndex?: number | null;
correctOptionIndices?: number[] | null;
explanation?: string | null;
subQuestions?: Record<string, unknown>[] | null;
hasVideoExplanation?: boolean;
wrongCount?: number;
lastWrongAt?: string | null;
@@ -41,6 +43,10 @@ export interface AnswerResult {
id: string;
questionId: string;
isCorrect: boolean | null;
selectedOptions?: string[];
answerText?: string | null;
answeredAt?: string;
selfJudged?: boolean;
}
export interface PracticeReport {
@@ -128,6 +134,7 @@ export async function submitAnswer(body: {
questionId: string;
selectedOptions?: string[];
answerText?: string;
selfJudgedCorrect?: boolean;
}) {
return apiRequest<{ item: AnswerResult }>('/api/learning/answers', {
method: 'POST',