forked from wangziqi/gongxue-base
feat: add safe rich content rendering for student flows
This commit is contained in:
346
apps/taro/src/components/RichContent.tsx
Normal file
346
apps/taro/src/components/RichContent.tsx
Normal 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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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\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>
|
||||
);
|
||||
}
|
||||
214
apps/taro/src/components/rich-content.css
Normal file
214
apps/taro/src/components/rich-content.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user