feat: add taro student learning flow

This commit is contained in:
Codex
2026-06-29 12:04:59 +08:00
parent c090008f52
commit b79c4259c0
28 changed files with 1567 additions and 12 deletions

View File

@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react';
import Taro, { useRouter } from '@tarojs/taro';
import { Button, Text, Textarea, View } from '@tarojs/components';
import { loadCollectionQuestions, loadQuestions } from '@/services/catalog';
import { createPracticeSession, submitAnswer, toggleQuestionFavorite, type PracticeSession, type QuestionItem } from '@/services/learning';
import '../student.css';
function plainOption(option: unknown, index: number) {
if (typeof option === 'string') return option;
if (option && typeof option === 'object') {
const data = option as Record<string, unknown>;
return String(data.text || data.content || data.label || data.value || `选项 ${index + 1}`);
}
return `选项 ${index + 1}`;
}
export default function StudentPracticePage() {
const router = useRouter();
const params = router.params || {};
const [session, setSession] = useState<PracticeSession | null>(null);
const [questions, setQuestions] = useState<QuestionItem[]>([]);
const [index, setIndex] = useState(0);
const [selected, setSelected] = useState<string[]>([]);
const [answerText, setAnswerText] = useState('');
const [resultByQuestion, setResultByQuestion] = useState<Record<string, boolean | null>>({});
const [error, setError] = useState('');
useEffect(() => {
const collectionId = params.collectionId || undefined;
const body = {
blueprintId: params.blueprintId || undefined,
collectionId,
entryId: params.entryId || undefined,
contentNodeId: params.contentNodeId || undefined,
mode: params.mode || 'sequential',
questionLimit: 50,
};
createPracticeSession(body)
.then(async payload => {
const nextSession = payload.item;
setSession(nextSession);
const questionPayload = collectionId
? await loadCollectionQuestions(collectionId, 300)
: await loadQuestions({ entryId: body.entryId, contentNodeId: body.contentNodeId, limit: 300 });
const byId = new Map((questionPayload.items || []).map(item => [item.id, item]));
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 : '练习创建失败'));
}, []);
const current = questions[index];
const options = useMemo(() => Array.isArray(current?.options) ? current.options : [], [current]);
const isSubmitted = current ? current.id in resultByQuestion : false;
function toggleOption(optionIndex: number) {
const value = String(optionIndex);
setSelected(prev => prev.includes(value) ? prev.filter(item => item !== value) : [...prev, value]);
}
async function handleSubmit() {
if (!session || !current) return;
try {
const payload = await submitAnswer({
practiceSessionId: session.id,
questionId: current.id,
selectedOptions: selected,
answerText,
});
setResultByQuestion(prev => ({ ...prev, [current.id]: payload.item.isCorrect }));
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '提交失败');
}
}
function nextQuestion() {
setSelected([]);
setAnswerText('');
setIndex(prev => Math.min(questions.length - 1, prev + 1));
}
async function handleFavorite() {
if (!current) return;
await toggleQuestionFavorite(current.id, true).catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败'));
}
return (
<View className='student-page'>
<View className='student-topbar'>
<View className='student-title-block'>
<Text className='student-kicker'>{session ? `${index + 1}/${questions.length || session.questionCount}` : 'Practice'}</Text>
<Text className='student-title'>{session?.mode || '练习'}</Text>
<Text className='student-subtitle'> session 访</Text>
</View>
</View>
{current ? (
<View className='list-stack'>
<View className='quiet-panel'>
<Text className='row-meta'>{current.typeLabel || current.type || '题目'}</Text>
<Text className='row-main'>{current.content || '未提供题干'}</Text>
</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>
)) : (
<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>
{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={nextQuestion}></Button>
</View>
</View>
) : <View className='empty-state'></View>}
{error ? <Text className='error-text'>{error}</Text> : null}
</View>
);
}