feat: add safe rich content rendering for student flows

This commit is contained in:
Codex
2026-06-29 23:53:15 +08:00
parent c6a21d94bb
commit e17b44274b
12 changed files with 787 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Text, View } from '@tarojs/components';
import RichContent from '@/components/RichContent';
import { loadHandbookChapters, loadHandbookEntries, loadHandbookSubjects, type HandbookChapter, type HandbookEntry, type HandbookSubject } from '@/services/catalog';
import '../student.css';
@@ -79,7 +80,10 @@ export default function StudentHandbookPage() {
{entries.map(item => (
<View className='list-row' key={item.id}>
<Text className='row-main'>{item.title}</Text>
<Text className='row-meta'>{item.summary || item.content || '暂无内容'}</Text>
{item.summary ? <RichContent content={item.summary} compact muted /> : null}
<View className='handbook-entry-body'>
<RichContent content={item.content || '暂无内容'} compact />
</View>
</View>
))}
</View>

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import Taro, { useRouter } from '@tarojs/taro';
import { Button, Text, Textarea, View } from '@tarojs/components';
import RichContent from '@/components/RichContent';
import { loadCollectionQuestions, loadQuestions } from '@/services/catalog';
import {
createPracticeSession,
@@ -527,8 +528,7 @@ export default function StudentPracticePage() {
<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}
<RichContent content={current.content || '未提供题干'} mediaUrls={[current.mediaUrl]} />
{hasCompositeSubQuestions ? <Text className='row-meta'> {subQuestions.length} </Text> : null}
</View>
{hasCompositeSubQuestions ? (
@@ -545,7 +545,7 @@ export default function StudentPracticePage() {
<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>
<RichContent content={String(subQuestion.content || '未提供子题题干')} compact />
{objective ? (
<View className='sub-option-list'>
{subOptions.map((option, optionIndex) => (
@@ -554,7 +554,10 @@ export default function StudentPracticePage() {
key={`${id}-${optionIndex}`}
onClick={() => toggleSubOption(subQuestion, subQuestionIndex, optionIndex)}
>
<Text className='row-main'>{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)}</Text>
<View className='option-line'>
<Text className='option-prefix'>{String.fromCharCode(65 + optionIndex)}.</Text>
<RichContent className='option-rich' content={plainOption(option, optionIndex)} compact />
</View>
</View>
))}
</View>
@@ -579,8 +582,18 @@ export default function StudentPracticePage() {
<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}
{result?.correctAnswerText ? (
<View className='answer-detail'>
<Text className='answer-detail-label'></Text>
<RichContent content={result.correctAnswerText} compact muted />
</View>
) : null}
{result?.explanation ? (
<View className='answer-detail'>
<Text className='answer-detail-label'></Text>
<RichContent content={result.explanation} compact muted />
</View>
) : null}
</View>
) : null}
</View>
@@ -593,7 +606,10 @@ export default function StudentPracticePage() {
key={String(optionIndex)}
onClick={() => toggleOption(optionIndex)}
>
<Text className='row-main'>{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)}</Text>
<View className='option-line'>
<Text className='option-prefix'>{String.fromCharCode(65 + optionIndex)}.</Text>
<RichContent className='option-rich' content={plainOption(option, optionIndex)} compact />
</View>
</View>
)) : (
<View className='quiet-panel'>
@@ -609,10 +625,20 @@ export default function StudentPracticePage() {
<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.answerText ? (
<View className='answer-detail'>
<Text className='answer-detail-label'></Text>
<RichContent content={current.answerText} compact />
</View>
) : 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}
{current.explanation ? (
<View className='answer-detail'>
<Text className='answer-detail-label'></Text>
<RichContent content={current.explanation} compact muted />
</View>
) : null}
</View>
) : null}
<View className='toolbar wrap'>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import Taro, { useRouter } from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import RichContent from '@/components/RichContent';
import {
loadPracticeHistory,
loadPracticeReports,
@@ -14,6 +15,28 @@ function percent(value?: number) {
return `${Math.round((value || 0) * 100)}%`;
}
function stringValue(source: Record<string, unknown>, key: string) {
const value = source[key];
return typeof value === 'string' ? value : '';
}
function numberArray(source: Record<string, unknown>, key: string) {
const value = source[key];
return Array.isArray(value) ? value.map(item => Number(item)).filter(Number.isFinite) : [];
}
function answerChoiceLabel(value: unknown) {
const num = Number(value);
return Number.isFinite(num) ? String.fromCharCode(65 + num) : String(value);
}
function resultStatus(result: Record<string, unknown>) {
if (!result.answered) return '未答';
if (result.isCorrect === true) return '正确';
if (result.isCorrect === false) return '错误';
return '已答';
}
export default function StudentReportsPage() {
const router = useRouter();
const practiceSessionId = router.params?.practiceSessionId || '';
@@ -109,6 +132,68 @@ export default function StudentReportsPage() {
</View>
) : null}
{current?.questionResults?.length ? (
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>
{current.questionResults.map((raw, index) => {
const item = raw || {};
const result = item as Record<string, unknown>;
const selectedOptions = numberArray(result, 'selectedOptions').map(answerChoiceLabel);
const correctOptions = numberArray(result, 'correctOptionIndices').map(answerChoiceLabel);
const correctOptionIndex = result.correctOptionIndex;
if (!correctOptions.length && correctOptionIndex !== null && correctOptionIndex !== undefined) {
correctOptions.push(answerChoiceLabel(correctOptionIndex));
}
const subResults = Array.isArray(result.subResults) ? result.subResults as Record<string, unknown>[] : [];
return (
<View className='list-row' key={stringValue(result, 'questionId') || String(index)}>
<View className='amount-row'>
<Text className='row-meta'> {index + 1} · {stringValue(result, 'questionType') || '题目'}</Text>
<Text className={`status-badge ${result.isCorrect === false ? 'danger' : ''}`}>{resultStatus(result)}</Text>
</View>
<RichContent content={stringValue(result, 'content') || '暂无题干'} compact />
<Text className='row-meta'> {String(result.score ?? 0)} / {String(result.totalScore ?? 0)}</Text>
{selectedOptions.length ? <Text className='row-meta'>{selectedOptions.join('、')}</Text> : null}
{correctOptions.length ? <Text className='row-meta'>{correctOptions.join('、')}</Text> : null}
{stringValue(result, 'answerText') ? (
<View className='answer-detail'>
<Text className='answer-detail-label'></Text>
<RichContent content={stringValue(result, 'answerText')} compact muted />
</View>
) : null}
{stringValue(result, 'correctAnswerText') ? (
<View className='answer-detail'>
<Text className='answer-detail-label'></Text>
<RichContent content={stringValue(result, 'correctAnswerText')} compact />
</View>
) : null}
{stringValue(result, 'explanation') ? (
<View className='answer-detail'>
<Text className='answer-detail-label'></Text>
<RichContent content={stringValue(result, 'explanation')} compact muted />
</View>
) : null}
{subResults.length ? (
<View className='sub-explanation'>
<Text className='answer-detail-label'></Text>
{subResults.map((subResult, subIndex) => (
<View className='report-sub-result' key={stringValue(subResult, 'subQuestionId') || String(subIndex)}>
<Text className='row-meta'> {subIndex + 1} · {resultStatus(subResult)} · {String(subResult.score ?? 0)} / {String(subResult.totalScore ?? 0)}</Text>
<RichContent content={stringValue(subResult, 'content')} compact muted emptyText='暂无子题题干' />
{stringValue(subResult, 'correctAnswerText') ? <RichContent content={`参考答案:${stringValue(subResult, 'correctAnswerText')}`} compact muted /> : null}
{stringValue(subResult, 'explanation') ? <RichContent content={`解析:${stringValue(subResult, 'explanation')}`} compact muted /> : null}
</View>
))}
</View>
) : null}
</View>
);
})}
</View>
</View>
) : null}
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
);

View File

@@ -156,6 +156,40 @@
border-top: 1px solid #e2e8f0;
}
.option-line {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.option-prefix {
flex: 0 0 auto;
color: #0f172a;
font-size: 26px;
font-weight: 850;
line-height: 1.55;
}
.option-rich {
flex: 1;
min-width: 0;
}
.answer-detail {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.answer-detail-label {
display: block;
margin-bottom: 8px;
color: #334155;
font-size: 22px;
font-weight: 820;
}
.row-main {
display: block;
color: #111827;
@@ -303,6 +337,21 @@
font-weight: 750;
}
.status-badge.danger {
background: #fff1f2;
color: #be123c;
}
.handbook-entry-body {
margin-top: 14px;
}
.report-sub-result {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed #cbd5e1;
}
.answer-sheet {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));