forked from wangziqi/gongxue-base
feat: add taro student learning flow
This commit is contained in:
3
apps/taro/src/pages/student/practice/index.config.ts
Normal file
3
apps/taro/src/pages/student/practice/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '练习',
|
||||
});
|
||||
126
apps/taro/src/pages/student/practice/index.tsx
Normal file
126
apps/taro/src/pages/student/practice/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user