forked from wangziqi/gongxue-base
feat: improve student practice experience
This commit is contained in:
@@ -30,7 +30,7 @@ pages/student/login/index 短信登录
|
||||
pages/student/home/index 首页与功能入口
|
||||
pages/student/region/index 地区选择、目标地区保存
|
||||
pages/student/catalog/index 题库入口、分类、集合、练习蓝图
|
||||
pages/student/practice/index 创建练习 session、答题、收藏、反馈、视频入口、交卷报告
|
||||
pages/student/practice/index 创建练习 session、答题卡、进度恢复、倒计时、客观题判分、主观题自评、收藏、反馈、视频入口、交卷报告
|
||||
pages/student/review/index 错题本、收藏夹、错题/收藏复习
|
||||
pages/student/reports/index 练习报告、模考报告、历史报告
|
||||
pages/student/video/index 题目视频解析、播放签名
|
||||
@@ -43,7 +43,7 @@ pages/student/assets/index 资料列表、预览签名、下载签名
|
||||
pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章
|
||||
```
|
||||
|
||||
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善刷题细节、支付容器体验和小程序兼容。
|
||||
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、断点本地恢复、模拟倒计时、主观题后端自评、题目反馈、视频解析、交卷报告、错题本、收藏夹、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善阅读理解/案例分析多小题、长题干排版、公式图片混排、支付容器体验和小程序兼容。
|
||||
|
||||
学生端商城链路的安全边界:
|
||||
|
||||
@@ -101,7 +101,7 @@ TARO_APP_TENANT_CODE=<可选,小程序/预览环境使用>
|
||||
- 业务数据默认走 `apps/api`。
|
||||
- `x-tenant-id` 只是租户上下文,不是身份凭证。
|
||||
- 登录后不要传 `x-user-id` 或 body/query `userId` 伪造当前用户。
|
||||
- 题库练习、订单支付、内容导入、CRM、资料签名、后台配置必须走后端命令层。
|
||||
- 题库练习、答案判分、主观题自评、订单支付、内容导入、CRM、资料签名、后台配置必须走后端命令层。
|
||||
|
||||
## H5 部署建议
|
||||
|
||||
|
||||
@@ -7,13 +7,23 @@ import {
|
||||
submitAnswer,
|
||||
submitPracticeSession,
|
||||
toggleQuestionFavorite,
|
||||
type AnswerResult,
|
||||
type PracticeReport,
|
||||
type PracticeSession,
|
||||
type QuestionItem,
|
||||
} from '@/services/learning';
|
||||
import { submitFeedback } from '@/services/profile';
|
||||
import { getStorage, setStorage } from '@/services/storage';
|
||||
import '../student.css';
|
||||
|
||||
type AnswerState = {
|
||||
selectedOptions: string[];
|
||||
answerText: string;
|
||||
isCorrect: boolean | null;
|
||||
answeredAt?: string;
|
||||
selfJudged?: boolean;
|
||||
};
|
||||
|
||||
function plainOption(option: unknown, index: number) {
|
||||
if (typeof option === 'string') return option;
|
||||
if (option && typeof option === 'object') {
|
||||
@@ -23,6 +33,39 @@ function plainOption(option: unknown, index: number) {
|
||||
return `选项 ${index + 1}`;
|
||||
}
|
||||
|
||||
function isObjectiveQuestion(question?: QuestionItem | null) {
|
||||
if (!question) return false;
|
||||
return Array.isArray(question.options) && question.options.length > 0;
|
||||
}
|
||||
|
||||
function isMultiQuestion(question?: QuestionItem | null) {
|
||||
if (!question) return false;
|
||||
const type = String(question.type || '').toLowerCase();
|
||||
return type.includes('multi') || (question.correctOptionIndices?.length || 0) > 1;
|
||||
}
|
||||
|
||||
function answerStorageKey(sessionId: string) {
|
||||
return `tiku:practice:${sessionId}:answers`;
|
||||
}
|
||||
|
||||
function indexStorageKey(sessionId: string) {
|
||||
return `tiku:practice:${sessionId}:index`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const safeSeconds = Math.max(0, Math.trunc(seconds));
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const rest = safeSeconds % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function answerLabel(state?: AnswerState) {
|
||||
if (!state) return '未答';
|
||||
if (state.isCorrect === true) return '正确';
|
||||
if (state.isCorrect === false) return '错误';
|
||||
return state.selfJudged ? '已自评' : '已答';
|
||||
}
|
||||
|
||||
export default function StudentPracticePage() {
|
||||
const router = useRouter();
|
||||
const params = router.params || {};
|
||||
@@ -32,7 +75,12 @@ export default function StudentPracticePage() {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [answerText, setAnswerText] = useState('');
|
||||
const [feedbackText, setFeedbackText] = useState('');
|
||||
const [resultByQuestion, setResultByQuestion] = useState<Record<string, boolean | null>>({});
|
||||
const [answerByQuestion, setAnswerByQuestion] = useState<Record<string, AnswerState>>({});
|
||||
const [favoriteByQuestion, setFavoriteByQuestion] = useState<Record<string, boolean>>({});
|
||||
const [showExplanation, setShowExplanation] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [report, setReport] = useState<PracticeReport | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -50,6 +98,11 @@ export default function StudentPracticePage() {
|
||||
.then(async payload => {
|
||||
const nextSession = payload.item;
|
||||
setSession(nextSession);
|
||||
if (nextSession.durationMinutes) setTimeLeft(nextSession.durationMinutes * 60);
|
||||
const savedIndex = getStorage<number>(indexStorageKey(nextSession.id));
|
||||
const savedAnswers = getStorage<Record<string, AnswerState>>(answerStorageKey(nextSession.id));
|
||||
if (savedAnswers) setAnswerByQuestion(savedAnswers);
|
||||
if (typeof savedIndex === 'number') setIndex(savedIndex);
|
||||
const questionPayload = collectionId
|
||||
? await loadCollectionQuestions(collectionId, 300)
|
||||
: await loadQuestions({
|
||||
@@ -62,43 +115,133 @@ export default function StudentPracticePage() {
|
||||
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 : '练习创建失败'));
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '练习创建失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const current = questions[index];
|
||||
const options = useMemo(() => Array.isArray(current?.options) ? current.options : [], [current]);
|
||||
const isSubmitted = current ? current.id in resultByQuestion : false;
|
||||
const answerState = current ? answerByQuestion[current.id] : undefined;
|
||||
const isSubmitted = !!answerState;
|
||||
const answeredCount = Object.keys(answerByQuestion).length;
|
||||
const correctCount = Object.values(answerByQuestion).filter(item => item.isCorrect === true).length;
|
||||
const wrongCount = Object.values(answerByQuestion).filter(item => item.isCorrect === false).length;
|
||||
const progressPercent = questions.length ? Math.round((answeredCount / questions.length) * 100) : 0;
|
||||
|
||||
function toggleOption(optionIndex: number) {
|
||||
const value = String(optionIndex);
|
||||
setSelected(prev => prev.includes(value) ? prev.filter(item => item !== value) : [...prev, value]);
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
setStorage(indexStorageKey(session.id), index);
|
||||
}, [index, session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
setStorage(answerStorageKey(session.id), answerByQuestion);
|
||||
}, [answerByQuestion, session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
const state = answerByQuestion[current.id];
|
||||
setSelected(state?.selectedOptions || []);
|
||||
setAnswerText(state?.answerText || '');
|
||||
setFeedbackText('');
|
||||
setShowExplanation(!!state);
|
||||
}, [current?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft === null || report || completed) return;
|
||||
if (timeLeft <= 0) {
|
||||
void handleSubmitSession(true);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setTimeLeft(prev => (prev === null ? null : Math.max(0, prev - 1))), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [timeLeft, report, completed]);
|
||||
|
||||
function rememberAnswer(
|
||||
questionId: string,
|
||||
result: AnswerResult | { isCorrect: boolean | null; answeredAt?: string; selfJudged?: boolean },
|
||||
nextSelected = selected,
|
||||
nextAnswerText = answerText,
|
||||
) {
|
||||
const nextState: AnswerState = {
|
||||
selectedOptions: [...nextSelected],
|
||||
answerText: nextAnswerText,
|
||||
isCorrect: result.isCorrect,
|
||||
answeredAt: 'answeredAt' in result ? result.answeredAt : undefined,
|
||||
selfJudged: 'selfJudged' in result ? result.selfJudged : undefined,
|
||||
};
|
||||
setAnswerByQuestion(prev => ({ ...prev, [questionId]: nextState }));
|
||||
setShowExplanation(true);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
function toggleOption(optionIndex: number) {
|
||||
if (isSubmitted) return;
|
||||
const value = String(optionIndex);
|
||||
const nextSelected = selected.includes(value) ? selected.filter(item => item !== value) : [...selected, value];
|
||||
setSelected(nextSelected);
|
||||
if (current && !isMultiQuestion(current) && nextSelected.length === 1) {
|
||||
void handleSubmit(nextSelected);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(nextSelected = selected, selfCorrect?: boolean) {
|
||||
if (!session || !current) return;
|
||||
if (!isObjectiveQuestion(current) && selfCorrect !== undefined) {
|
||||
try {
|
||||
const payload = await submitAnswer({
|
||||
practiceSessionId: session.id,
|
||||
questionId: current.id,
|
||||
selectedOptions: [],
|
||||
answerText,
|
||||
selfJudgedCorrect: selfCorrect,
|
||||
});
|
||||
rememberAnswer(current.id, payload.item);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '提交失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await submitAnswer({
|
||||
practiceSessionId: session.id,
|
||||
questionId: current.id,
|
||||
selectedOptions: selected,
|
||||
selectedOptions: nextSelected,
|
||||
answerText,
|
||||
});
|
||||
setResultByQuestion(prev => ({ ...prev, [current.id]: payload.item.isCorrect }));
|
||||
rememberAnswer(current.id, payload.item, nextSelected);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '提交失败');
|
||||
}
|
||||
}
|
||||
|
||||
function previousQuestion() {
|
||||
setIndex(prev => Math.max(0, prev - 1));
|
||||
setCompleted(false);
|
||||
}
|
||||
|
||||
function nextQuestion() {
|
||||
setSelected([]);
|
||||
setAnswerText('');
|
||||
setFeedbackText('');
|
||||
if (index >= questions.length - 1) {
|
||||
setCompleted(true);
|
||||
return;
|
||||
}
|
||||
setIndex(prev => Math.min(questions.length - 1, prev + 1));
|
||||
setCompleted(false);
|
||||
}
|
||||
|
||||
function jumpQuestion(nextIndex: number) {
|
||||
setIndex(Math.max(0, Math.min(questions.length - 1, nextIndex)));
|
||||
setCompleted(false);
|
||||
}
|
||||
|
||||
async function handleFavorite() {
|
||||
if (!current) return;
|
||||
await toggleQuestionFavorite(current.id, true).catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败'));
|
||||
const nextFavorite = !favoriteByQuestion[current.id];
|
||||
await toggleQuestionFavorite(current.id, nextFavorite)
|
||||
.then(payload => {
|
||||
setFavoriteByQuestion(prev => ({ ...prev, [current.id]: payload.favorite }));
|
||||
Taro.showToast({ title: payload.favorite ? '已收藏' : '已取消', icon: 'success' });
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败'));
|
||||
}
|
||||
|
||||
async function handleFeedback() {
|
||||
@@ -121,18 +264,21 @@ export default function StudentPracticePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitSession() {
|
||||
async function handleSubmitSession(auto = false) {
|
||||
if (!session) return;
|
||||
const ok = await Taro.showModal({
|
||||
title: '交卷',
|
||||
content: '确认交卷并生成练习报告?报告会按后端 session 快照评分。',
|
||||
confirmText: '交卷',
|
||||
cancelText: '取消',
|
||||
});
|
||||
if (!ok.confirm) return;
|
||||
if (!auto) {
|
||||
const ok = await Taro.showModal({
|
||||
title: '交卷',
|
||||
content: `已答 ${answeredCount}/${questions.length},确认交卷并生成练习报告?`,
|
||||
confirmText: '交卷',
|
||||
cancelText: '取消',
|
||||
});
|
||||
if (!ok.confirm) return;
|
||||
}
|
||||
try {
|
||||
const payload = await submitPracticeSession(session.id);
|
||||
setReport(payload.item || null);
|
||||
setCompleted(true);
|
||||
Taro.showToast({ title: '报告已生成', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '交卷失败');
|
||||
@@ -157,8 +303,20 @@ export default function StudentPracticePage() {
|
||||
<Text className='student-title'>{session?.mode || '练习'}</Text>
|
||||
<Text className='student-subtitle'>题目来自后端 session 快照,答题、交卷、错题和报告都以后端校验为准。</Text>
|
||||
</View>
|
||||
{timeLeft !== null ? <Text className='status-badge'>{formatDuration(timeLeft)}</Text> : null}
|
||||
</View>
|
||||
|
||||
{session ? (
|
||||
<View className='quiet-panel section-block'>
|
||||
<View className='amount-row'>
|
||||
<Text className='row-main'>进度 {answeredCount}/{questions.length}</Text>
|
||||
<Text className='status-badge'>{progressPercent}%</Text>
|
||||
</View>
|
||||
<Text className='row-meta'>正确 {correctCount} · 错误 {wrongCount} · 未答 {Math.max(0, questions.length - answeredCount)} · {session.accessMode || 'access'}</Text>
|
||||
{session.consumedFreeQuota ? <Text className='row-meta'>本次消耗免费题量 {session.consumedFreeQuota}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{report ? (
|
||||
<View className='report-panel section-block'>
|
||||
<Text className='metric-value'>{String(report.score)} / {String(report.totalScore)}</Text>
|
||||
@@ -169,31 +327,65 @@ export default function StudentPracticePage() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{current ? (
|
||||
{completed && !report ? (
|
||||
<View className='report-panel section-block'>
|
||||
<Text className='metric-value'>已完成本组练习</Text>
|
||||
<Text className='metric-label'>已答 {answeredCount}/{questions.length} · 正确 {correctCount} · 错误 {wrongCount}</Text>
|
||||
<View className='toolbar wrap'>
|
||||
<Button className='primary-button' onClick={() => handleSubmitSession()}>生成报告</Button>
|
||||
<Button className='secondary-button' onClick={() => jumpQuestion(0)}>重新查看</Button>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/review/index?type=wrong' })}>查看错题</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loading ? <View className='empty-state'>练习加载中...</View> : null}
|
||||
|
||||
{current && !completed ? (
|
||||
<View className='list-stack'>
|
||||
<View className='quiet-panel'>
|
||||
<Text className='row-meta'>{current.typeLabel || current.type || '题目'}</Text>
|
||||
<View className='amount-row'>
|
||||
<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}
|
||||
{current.subQuestions?.length ? <Text className='row-meta'>包含 {current.subQuestions.length} 个子题,请结合题干作答。</Text> : null}
|
||||
</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
|
||||
className={`list-row ${selected.includes(String(optionIndex)) ? 'active' : ''}`}
|
||||
key={String(optionIndex)}
|
||||
onClick={() => toggleOption(optionIndex)}
|
||||
>
|
||||
<Text className='row-main'>{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>
|
||||
<Textarea className='textarea' placeholder='请输入答案或先思考后查看参考答案' value={answerText} onInput={event => setAnswerText(String(event.detail.value || ''))} />
|
||||
<View className='toolbar wrap'>
|
||||
<Button className='secondary-button' onClick={() => setShowExplanation(true)}>查看答案</Button>
|
||||
<Button className='primary-button' onClick={() => handleSubmit(selected, true)}>答对了</Button>
|
||||
<Button className='danger-button' onClick={() => handleSubmit(selected, false)}>答错了</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{showExplanation || isSubmitted ? (
|
||||
<View className='quiet-panel'>
|
||||
{isSubmitted ? <Text className={answerState?.isCorrect ? 'success-text' : answerState?.isCorrect === false ? 'error-text' : 'row-meta'}>{answerLabel(answerState)}</Text> : null}
|
||||
{current.answerText ? <Text className='row-main'>参考答案:{current.answerText}</Text> : 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}
|
||||
</View>
|
||||
) : null}
|
||||
<View className='toolbar'>
|
||||
<Button className='primary-button' onClick={handleSubmit}>提交</Button>
|
||||
<Button className='secondary-button' onClick={handleFavorite}>收藏</Button>
|
||||
<Button className='secondary-button' onClick={openVideo}>视频</Button>
|
||||
<View className='toolbar wrap'>
|
||||
{isObjectiveQuestion(current) ? <Button className='primary-button' onClick={() => handleSubmit()}>提交</Button> : null}
|
||||
<Button className='secondary-button' onClick={previousQuestion}>上一题</Button>
|
||||
<Button className='secondary-button' onClick={handleFavorite}>{favoriteByQuestion[current.id] ? '取消收藏' : '收藏'}</Button>
|
||||
{current.hasVideoExplanation ? <Button className='secondary-button' onClick={openVideo}>视频</Button> : null}
|
||||
<Button className='secondary-button' onClick={nextQuestion}>下一题</Button>
|
||||
<Button className='secondary-button' onClick={handleSubmitSession}>交卷</Button>
|
||||
<Button className='secondary-button' onClick={() => handleSubmitSession()}>交卷</Button>
|
||||
</View>
|
||||
<View className='quiet-panel'>
|
||||
<Text className='row-main'>题目反馈</Text>
|
||||
@@ -203,7 +395,24 @@ export default function StudentPracticePage() {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : <View className='empty-state'>暂无题目。请确认后台已发布题目并配置集合或练习蓝图。</View>}
|
||||
) : !loading && !completed ? <View className='empty-state'>暂无题目。请确认后台已发布题目并配置集合或练习蓝图。</View> : null}
|
||||
|
||||
{questions.length ? (
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>答题卡</Text>
|
||||
<View className='answer-sheet'>
|
||||
{questions.map((item, cardIndex) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
className={`answer-card ${cardIndex === index ? 'current' : ''} ${answerByQuestion[item.id] ? 'answered' : ''} ${answerByQuestion[item.id]?.isCorrect === false ? 'wrong' : ''}`}
|
||||
onClick={() => jumpQuestion(cardIndex)}
|
||||
>
|
||||
{cardIndex + 1}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -286,6 +286,42 @@
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.answer-sheet {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.answer-card {
|
||||
min-width: 0;
|
||||
height: 58px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 58px;
|
||||
}
|
||||
|
||||
.answer-card.current {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.answer-card.answered {
|
||||
border-color: #bbf7d0;
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.answer-card.wrong {
|
||||
border-color: #fecaca;
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.break-text {
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@@ -14,11 +14,13 @@ export interface QuestionItem {
|
||||
content?: string;
|
||||
type?: string;
|
||||
typeLabel?: string;
|
||||
mediaUrl?: string | null;
|
||||
options?: QuestionOption[] | string[] | null;
|
||||
answerText?: string | null;
|
||||
correctOptionIndex?: number | null;
|
||||
correctOptionIndices?: number[] | null;
|
||||
explanation?: string | null;
|
||||
subQuestions?: Record<string, unknown>[] | null;
|
||||
hasVideoExplanation?: boolean;
|
||||
wrongCount?: number;
|
||||
lastWrongAt?: string | null;
|
||||
@@ -41,6 +43,10 @@ export interface AnswerResult {
|
||||
id: string;
|
||||
questionId: string;
|
||||
isCorrect: boolean | null;
|
||||
selectedOptions?: string[];
|
||||
answerText?: string | null;
|
||||
answeredAt?: string;
|
||||
selfJudged?: boolean;
|
||||
}
|
||||
|
||||
export interface PracticeReport {
|
||||
@@ -128,6 +134,7 @@ export async function submitAnswer(body: {
|
||||
questionId: string;
|
||||
selectedOptions?: string[];
|
||||
answerText?: string;
|
||||
selfJudgedCorrect?: boolean;
|
||||
}) {
|
||||
return apiRequest<{ item: AnswerResult }>('/api/learning/answers', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user