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

@@ -0,0 +1,346 @@
import Taro from '@tarojs/taro';
import { Image, Text, View } from '@tarojs/components';
import './rich-content.css';
type InlineToken =
| { type: 'text'; text: string }
| { type: 'bold'; text: string }
| { type: 'code'; text: string }
| { type: 'formula'; text: string; display?: boolean };
type RichBlock =
| { type: 'paragraph'; tokens: InlineToken[] }
| { type: 'heading'; level: number; tokens: InlineToken[] }
| { type: 'formula'; text: string }
| { type: 'code'; language?: string; text: string }
| { type: 'image'; url: string; alt: string }
| { type: 'blocked-image'; alt: string }
| { type: 'table'; header: string[]; align: string[]; rows: string[][] };
export interface RichContentProps {
content?: unknown;
className?: string;
compact?: boolean;
muted?: boolean;
mediaUrls?: Array<string | null | undefined>;
emptyText?: string;
}
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
function classNames(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(' ');
}
function decodeHtmlEntities(value: string) {
return value
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&#(\d+);/g, (_, code) => {
const value = Number(code);
return Number.isFinite(value) ? String.fromCharCode(value) : '';
})
.replace(/&#x([0-9a-f]+);/gi, (_, code) => {
const value = Number.parseInt(code, 16);
return Number.isFinite(value) ? String.fromCharCode(value) : '';
});
}
function attrValue(attrs: string, name: string) {
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, 'i');
return attrs.match(pattern)?.[1] || '';
}
function normalizeSource(value: unknown) {
if (value === undefined || value === null) return '';
return decodeHtmlEntities(String(value))
.replace(/\r\n?/g, '\n')
.replace(/\u0000/g, '')
.replace(/<script\b[\s\S]*?<\/script>/gi, '')
.replace(/<style\b[\s\S]*?<\/style>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<p\b[^>]*>/gi, '')
.replace(/<img\b([^>]*)>/gi, (_, attrs) => {
const src = attrValue(attrs, 'src');
const alt = attrValue(attrs, 'alt');
return src ? `\n![${alt || '图片'}](${src})\n` : '';
})
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**')
.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, '`$1`')
.replace(/<\/?[a-z][^>]*>/gi, '');
}
export function safeTextContent(value: unknown) {
return normalizeSource(value).trim();
}
export function normalizeSafeMediaUrl(value: unknown) {
const raw = typeof value === 'string' ? value.trim() : '';
if (!raw) return null;
if (/^(javascript|data|vbscript):/i.test(raw)) return null;
if (raw.startsWith('//')) return null;
if (raw.startsWith('/')) return raw;
try {
const url = new URL(raw);
if (url.protocol === 'https:') return url.href;
if (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)) return url.href;
} catch (_error) {
return null;
}
return null;
}
function splitTableCells(line: string) {
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return trimmed.split('|').map(cell => cell.trim());
}
function isTableSeparator(line?: string) {
if (!line) return false;
const cells = splitTableCells(line);
return cells.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell.trim()));
}
function tableAligns(separator: string) {
return splitTableCells(separator).map(cell => {
if (cell.startsWith(':') && cell.endsWith(':')) return 'center';
if (cell.endsWith(':')) return 'right';
return 'left';
});
}
function parseInline(value: string): InlineToken[] {
const tokens: InlineToken[] = [];
const pattern = /(\*\*[^*]+\*\*|`[^`]+`|\\\([^)]+\\\)|\\\[[\s\S]+?\\\]|\$[^$\n]+\$)/g;
let cursor = 0;
for (const match of value.matchAll(pattern)) {
const index = match.index || 0;
if (index > cursor) tokens.push({ type: 'text', text: value.slice(cursor, index) });
const raw = match[0];
if (raw.startsWith('**')) {
tokens.push({ type: 'bold', text: raw.slice(2, -2) });
} else if (raw.startsWith('`')) {
tokens.push({ type: 'code', text: raw.slice(1, -1) });
} else if (raw.startsWith('\\[')) {
tokens.push({ type: 'formula', text: raw.slice(2, -2), display: true });
} else if (raw.startsWith('\\(')) {
tokens.push({ type: 'formula', text: raw.slice(2, -2) });
} else {
tokens.push({ type: 'formula', text: raw.slice(1, -1) });
}
cursor = index + raw.length;
}
if (cursor < value.length) tokens.push({ type: 'text', text: value.slice(cursor) });
return tokens.filter(token => token.text.length > 0);
}
function appendParagraphWithImages(blocks: RichBlock[], rawText: string) {
const text = rawText.trim();
if (!text) return;
IMAGE_RE.lastIndex = 0;
let cursor = 0;
let matched = false;
for (const match of text.matchAll(IMAGE_RE)) {
matched = true;
const index = match.index || 0;
const before = text.slice(cursor, index).trim();
if (before) blocks.push({ type: 'paragraph', tokens: parseInline(before) });
const url = normalizeSafeMediaUrl(match[2]);
if (url) {
blocks.push({ type: 'image', url, alt: match[1] || '图片' });
} else {
blocks.push({ type: 'blocked-image', alt: match[1] || '图片' });
}
cursor = index + match[0].length;
}
const rest = text.slice(cursor).trim();
if (rest || !matched) blocks.push({ type: 'paragraph', tokens: parseInline(rest || text) });
}
function parseBlocks(input: unknown, mediaUrls: RichContentProps['mediaUrls'] = []) {
const source = normalizeSource(input);
const blocks: RichBlock[] = [];
const paragraph: string[] = [];
function flushParagraph() {
if (!paragraph.length) return;
appendParagraphWithImages(blocks, paragraph.join('\n'));
paragraph.length = 0;
}
const lines = source.split('\n');
let index = 0;
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
index += 1;
continue;
}
const codeStart = trimmed.match(/^```([a-z0-9_-]+)?\s*$/i);
if (codeStart) {
flushParagraph();
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
blocks.push({ type: 'code', language: codeStart[1], text: codeLines.join('\n') });
continue;
}
if (trimmed.startsWith('$$')) {
flushParagraph();
const formulaLines: string[] = [];
const first = trimmed.replace(/^\$\$/, '');
if (first.endsWith('$$') && first.length > 2) {
formulaLines.push(first.replace(/\$\$$/, ''));
index += 1;
} else {
if (first) formulaLines.push(first);
index += 1;
while (index < lines.length && !lines[index].trim().endsWith('$$')) {
formulaLines.push(lines[index]);
index += 1;
}
if (index < lines.length) {
formulaLines.push(lines[index].trim().replace(/\$\$$/, ''));
index += 1;
}
}
blocks.push({ type: 'formula', text: formulaLines.join('\n').trim() });
continue;
}
const heading = trimmed.match(/^(#{1,4})\s+(.+)$/);
if (heading) {
flushParagraph();
blocks.push({ type: 'heading', level: heading[1].length, tokens: parseInline(heading[2]) });
index += 1;
continue;
}
if (trimmed.includes('|') && isTableSeparator(lines[index + 1])) {
flushParagraph();
const header = splitTableCells(trimmed);
const align = tableAligns(lines[index + 1]);
const rows: string[][] = [];
index += 2;
while (index < lines.length && lines[index].trim().includes('|')) {
rows.push(splitTableCells(lines[index]));
index += 1;
}
blocks.push({ type: 'table', header, align, rows });
continue;
}
paragraph.push(line);
index += 1;
}
flushParagraph();
for (const mediaUrl of mediaUrls || []) {
const url = normalizeSafeMediaUrl(mediaUrl);
if (url) blocks.push({ type: 'image', url, alt: '题目图片' });
}
return blocks;
}
function renderInline(token: InlineToken, index: number) {
if (token.type === 'bold') {
return <Text key={index} className='rich-bold'>{token.text}</Text>;
}
if (token.type === 'code') {
return <Text key={index} className='rich-code-inline'>{token.text}</Text>;
}
if (token.type === 'formula') {
return <Text key={index} className={token.display ? 'rich-formula-inline display' : 'rich-formula-inline'}>{token.text}</Text>;
}
return <Text key={index}>{token.text}</Text>;
}
function preview(url: string) {
Taro.previewImage({ current: url, urls: [url] }).catch(() => undefined);
}
export default function RichContent({ content, className, compact = false, muted = false, mediaUrls, emptyText }: RichContentProps) {
const blocks = parseBlocks(content, mediaUrls);
if (!blocks.length) {
return emptyText ? <Text className='rich-empty'>{emptyText}</Text> : null;
}
return (
<View className={classNames('rich-content', compact && 'compact', muted && 'muted', className)}>
{blocks.map((block, index) => {
if (block.type === 'heading') {
return (
<View key={index} className={classNames('rich-heading', `level-${block.level}`)}>
{block.tokens.map(renderInline)}
</View>
);
}
if (block.type === 'paragraph') {
return (
<View key={index} className='rich-paragraph'>
{block.tokens.map(renderInline)}
</View>
);
}
if (block.type === 'formula') {
return <View key={index} className='rich-formula-block'><Text>{block.text}</Text></View>;
}
if (block.type === 'code') {
return (
<View key={index} className='rich-code-block'>
{block.language ? <Text className='rich-code-language'>{block.language}</Text> : null}
<Text className='rich-code-text'>{block.text}</Text>
</View>
);
}
if (block.type === 'image') {
return (
<View key={index} className='rich-image-wrap' onClick={() => preview(block.url)}>
<Image className='rich-image' src={block.url} mode='widthFix' />
{block.alt ? <Text className='rich-image-caption'>{block.alt}</Text> : null}
</View>
);
}
if (block.type === 'blocked-image') {
return <View key={index} className='rich-blocked-image'>{block.alt}</View>;
}
return (
<View key={index} className='rich-table'>
<View className='rich-table-row header'>
{block.header.map((cell, cellIndex) => (
<View key={cellIndex} className={classNames('rich-table-cell', block.align[cellIndex])}>
{parseInline(cell).map(renderInline)}
</View>
))}
</View>
{block.rows.map((row, rowIndex) => (
<View key={rowIndex} className='rich-table-row'>
{block.header.map((_, cellIndex) => (
<View key={cellIndex} className={classNames('rich-table-cell', block.align[cellIndex])}>
{parseInline(row[cellIndex] || '').map(renderInline)}
</View>
))}
</View>
))}
</View>
);
})}
</View>
);
}

View File

@@ -0,0 +1,214 @@
.rich-content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
color: #111827;
}
.rich-content.compact {
gap: 10px;
}
.rich-content.muted {
color: #475569;
}
.rich-paragraph,
.rich-heading {
display: block;
min-width: 0;
color: inherit;
font-size: 28px;
font-weight: 520;
line-height: 1.62;
word-break: break-word;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.rich-content.compact .rich-paragraph {
font-size: 24px;
line-height: 1.55;
}
.rich-content.muted .rich-paragraph {
color: #64748b;
}
.rich-heading {
color: #0f172a;
font-size: 32px;
font-weight: 820;
line-height: 1.35;
}
.rich-heading.level-2 {
font-size: 30px;
}
.rich-heading.level-3,
.rich-heading.level-4 {
font-size: 28px;
}
.rich-bold {
color: #0f172a;
font-weight: 820;
}
.rich-code-inline {
display: inline-flex;
max-width: 100%;
padding: 0 8px;
border: 1px solid #dbe3ee;
border-radius: 6px;
background: #f8fafc;
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 23px;
line-height: 1.35;
word-break: break-all;
}
.rich-formula-inline {
display: inline-flex;
max-width: 100%;
margin: 0 4px;
padding: 0 8px;
border-radius: 6px;
background: #f8fafc;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 24px;
line-height: 1.45;
word-break: break-all;
}
.rich-formula-inline.display {
display: flex;
}
.rich-formula-block,
.rich-code-block {
min-width: 0;
padding: 18px;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #f8fafc;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 24px;
line-height: 1.55;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.rich-code-language {
display: block;
margin-bottom: 10px;
color: #64748b;
font-size: 20px;
font-weight: 800;
text-transform: uppercase;
}
.rich-code-text {
display: block;
color: inherit;
font-size: inherit;
line-height: inherit;
}
.rich-image-wrap {
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.rich-image {
display: block;
width: 100%;
max-height: 640px;
background: #f8fafc;
}
.rich-image-caption {
display: block;
padding: 10px 14px 14px;
color: #64748b;
font-size: 20px;
line-height: 1.4;
}
.rich-blocked-image {
padding: 16px;
border: 1px dashed #fca5a5;
border-radius: 8px;
background: #fff1f2;
color: #be123c;
font-size: 22px;
line-height: 1.4;
word-break: break-word;
}
.rich-table {
display: block;
width: 100%;
overflow-x: auto;
border: 1px solid #dbe3ee;
border-radius: 8px;
background: #fff;
}
.rich-table-row {
display: flex;
min-width: 680px;
border-top: 1px solid #e2e8f0;
}
.rich-table-row:first-child {
border-top: 0;
}
.rich-table-row.header {
background: #f8fafc;
}
.rich-table-cell {
flex: 1;
min-width: 160px;
padding: 14px 16px;
border-left: 1px solid #e2e8f0;
color: #334155;
font-size: 22px;
line-height: 1.45;
word-break: break-word;
}
.rich-table-cell:first-child {
border-left: 0;
}
.rich-table-row.header .rich-table-cell {
color: #0f172a;
font-weight: 820;
}
.rich-table-cell.center {
text-align: center;
}
.rich-table-cell.right {
text-align: right;
}
.rich-empty {
color: #94a3b8;
font-size: 22px;
line-height: 1.45;
}

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));