forked from wangziqi/gongxue-base
feat: improve student practice experience
This commit is contained in:
@@ -226,6 +226,33 @@ function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerTe
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasObjectiveAnswer(row: QuestionAnswerRow) {
|
||||
return normalizeNumberArray(row.correct_option_indices).length > 0 ||
|
||||
(row.correct_option_index !== null && row.correct_option_index !== undefined);
|
||||
}
|
||||
|
||||
function optionalBodyBoolean(body: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const value = body[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
throw new HttpError(400, `${key} must be boolean`, 'INVALID_BOOLEAN_FIELD');
|
||||
}
|
||||
|
||||
function resolveJudgedAnswer(
|
||||
row: QuestionAnswerRow,
|
||||
selectedOptions: string[],
|
||||
answerText: string,
|
||||
selfJudgedCorrect: boolean | undefined,
|
||||
) {
|
||||
if (selfJudgedCorrect !== undefined) {
|
||||
if (hasObjectiveAnswer(row)) {
|
||||
throw new HttpError(400, 'Self judgment is only allowed for subjective questions', 'SELF_JUDGMENT_NOT_ALLOWED');
|
||||
}
|
||||
return selfJudgedCorrect;
|
||||
}
|
||||
return judgeAnswer(row, selectedOptions, answerText);
|
||||
}
|
||||
|
||||
function optionalBodyString(body: Record<string, unknown>, key: string) {
|
||||
const value = body[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
@@ -699,6 +726,7 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const selectedOptions = optionalStringArray(body, 'selectedOptions');
|
||||
const answerText = optionalString(body, 'answerText');
|
||||
const selfJudgedCorrect = optionalBodyBoolean(body, 'selfJudgedCorrect');
|
||||
const practiceSessionId = optionalString(body, 'practiceSessionId') || null;
|
||||
|
||||
const question = await queryOne<QuestionAnswerRow>(
|
||||
@@ -717,10 +745,9 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
|
||||
}
|
||||
|
||||
const judged = judgeAnswer(question, selectedOptions, answerText);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
await assertAnswerSessionAccess(client, { tenantId, userId, practiceSessionId, questionId });
|
||||
const judged = resolveJudgedAnswer(question, selectedOptions, answerText, selfJudgedCorrect);
|
||||
|
||||
const answerResult = await client.query(
|
||||
`
|
||||
@@ -774,7 +801,10 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
[tenantId, userId, judged],
|
||||
);
|
||||
|
||||
return answerResult.rows[0];
|
||||
return {
|
||||
...answerResult.rows[0],
|
||||
selfJudged: selfJudgedCorrect !== undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return { item: result };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 长题干/阅读理解/案例分析多小题体验、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||
@@ -35,7 +35,7 @@
|
||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 刷题细节 UI、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、本地进度恢复、模拟倒计时、主观题后端自评、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/多小题刷题 UI、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
|
||||
## 前端接入建议
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
3. 题库练习
|
||||
- 调 `GET /api/catalog/content-nodes`、`GET /api/catalog/question-collections`、`GET /api/catalog/practice-blueprints`。
|
||||
- 调 `POST /api/learning/practice-sessions` 生成顺序、随机、全真模拟题目快照。
|
||||
- 调 `POST /api/learning/answers`、错题、收藏接口完成刷题闭环。
|
||||
- 调 `POST /api/learning/answers`、错题、收藏接口完成刷题闭环;主观题自评传 `selfJudgedCorrect`,客观题仍以后端答案判分为准。
|
||||
|
||||
4. 背单词和知识手册
|
||||
- 背单词走 vocabulary units/words/progress/favorites。
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:长题干/阅读理解/案例分析多小题、勋章展示和小程序端分享/支付体验。地区选择、刷题答题卡、本地进度恢复、模拟倒计时、主观题后端自评、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
- `content_entries`
|
||||
- `content_nodes`
|
||||
@@ -66,7 +66,7 @@
|
||||
| 首页 | `apps/taro/src/pages/student/home/index.tsx` | `content-entries`、`banners`、`announcements`、`profile/me` |
|
||||
| 地区选择 | `apps/taro/src/pages/student/region/index.tsx` | `catalog/regions`、`profile/me`、`PATCH profile/me` |
|
||||
| 题库 | `apps/taro/src/pages/student/catalog/index.tsx` | `content-entries`、`content-nodes`、`question-collections`、`practice-blueprints` |
|
||||
| 练习 | `apps/taro/src/pages/student/practice/index.tsx` | `practice-sessions`、`questions`、`answers`、`favorites/questions`、`practice-sessions/submit`、`profile/feedbacks` |
|
||||
| 练习 | `apps/taro/src/pages/student/practice/index.tsx` | `practice-sessions`、`questions`、`answers`、`favorites/questions`、`practice-sessions/submit`、`profile/feedbacks`;已接答题卡、本地进度恢复、倒计时、主观题 `selfJudgedCorrect` |
|
||||
| 错题/收藏 | `apps/taro/src/pages/student/review/index.tsx` | `wrong-questions/review-plan`、`wrong-questions/resolve`、`favorites/questions`、`practice-sessions` |
|
||||
| 练习报告 | `apps/taro/src/pages/student/reports/index.tsx` | `practice-sessions/report`、`practice-reports` |
|
||||
| 视频解析 | `apps/taro/src/pages/student/video/index.tsx` | `questions/videos`、`videos/play` |
|
||||
@@ -78,7 +78,7 @@
|
||||
| 资料 | `apps/taro/src/pages/student/assets/index.tsx` | `assets`、`assets/preview`、`assets/download` |
|
||||
| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`leaderboard` |
|
||||
|
||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题/收藏/反馈/视频 -> 交卷报告 -> 错题/收藏复习”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补小程序分享/支付容器体验。
|
||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/收藏/反馈/视频 -> 交卷报告 -> 错题/收藏复习”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补阅读理解/案例分析多小题、小程序分享/支付容器体验。
|
||||
|
||||
## 已落地的 Taro 租户后台页面
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、考试倒计时、入口、个人统计有基础;缺完整运营动态和学习任务聚合 |
|
||||
| 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
|
||||
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
|
||||
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史、趋势统计和题目反馈已由后端强制;继续补断点续练 |
|
||||
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照、访问控制和历史统计,前端需按 mode 调用 |
|
||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排行榜/排名、断点续练、复盘体验 |
|
||||
| 顺序刷题 | `pages/Quiz.tsx` | 部分覆盖 | 免费额度/SVIP 校验、session 快照、练习历史、趋势统计和题目反馈已由后端强制;Taro 已有答题卡、本地进度恢复、客观题自动提交和主观题后端自评第一版;继续补后端权威断点续练、长题干和多小题体验 |
|
||||
| 随机刷题 | `pages/Quiz.tsx` | 部分覆盖 | 已有 blueprint/session 快照、访问控制和历史统计;Taro 已按 session 题目快照渲染第一版;继续补更完整复盘和随机刷题状态管理 |
|
||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、倒计时、交卷评分、分段统计和错题解析汇总已覆盖;后续补排行榜/排名、后端断点续练、完整复盘体验 |
|
||||
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 |
|
||||
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
||||
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
- 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。
|
||||
- 已完成单词复习算法、每日计划和复习上报。
|
||||
- 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。
|
||||
- 继续补断点续练和复盘体验。
|
||||
- Taro 已有本地断点恢复和倒计时第一版;继续补后端权威断点续练、复盘体验和多小题统计口径。
|
||||
|
||||
8. 订单和营销体验
|
||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
||||
@@ -165,7 +165,7 @@
|
||||
- H5 和小程序共用同一套业务 API client。
|
||||
- 租户通过域名、小程序配置或启动参数解析。
|
||||
- 页面主题、品牌、功能开关都从后端租户配置读取。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补刷题细节 UI、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、本地进度恢复、模拟倒计时、主观题后端自评、错题/收藏复习、题目反馈、视频解析、练习/模考报告、会员收银台、订单详情和售后入口第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、角色模板、成员绑定和 CRM/分佣操作台第一版;下一步补阅读理解/案例分析多小题、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
|
||||
### 第一批页面
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、本地进度恢复、模拟倒计时、主观题后端自评、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补阅读理解/案例分析多小题、长题干排版、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
|
||||
@@ -232,6 +232,69 @@ tenant:<tenantId>:theme
|
||||
- `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。
|
||||
- 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 session 的题目。
|
||||
|
||||
## 提交答案契约
|
||||
|
||||
客观题、主观题都统一调用:
|
||||
|
||||
```text
|
||||
POST /api/learning/answers
|
||||
```
|
||||
|
||||
单选/判断题示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"practiceSessionId": "...",
|
||||
"questionId": "...",
|
||||
"selectedOptions": ["1"]
|
||||
}
|
||||
```
|
||||
|
||||
多选题示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"practiceSessionId": "...",
|
||||
"questionId": "...",
|
||||
"selectedOptions": ["0", "2"]
|
||||
}
|
||||
```
|
||||
|
||||
填空、简答、翻译、案例分析等无客观选项的主观题,前端可以先展示参考答案,再让学生自评:
|
||||
|
||||
```json
|
||||
{
|
||||
"practiceSessionId": "...",
|
||||
"questionId": "...",
|
||||
"answerText": "学生自己的作答或备注",
|
||||
"selfJudgedCorrect": true
|
||||
}
|
||||
```
|
||||
|
||||
响应关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"item": {
|
||||
"id": "...",
|
||||
"questionId": "...",
|
||||
"selectedOptions": [],
|
||||
"answerText": "学生自己的作答或备注",
|
||||
"isCorrect": true,
|
||||
"answeredAt": "2026-06-29T00:00:00.000Z",
|
||||
"selfJudged": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端处理规则:
|
||||
|
||||
- 客观题不要传 `selfJudgedCorrect`。后端会用题库标准答案判分,传了会返回 `SELF_JUDGMENT_NOT_ALLOWED`。
|
||||
- 主观题自评也由后端落库为 `answer_records.is_correct`,错题本、练习统计、模考报告都以后端返回为准。
|
||||
- 前端可以在本地缓存当前 session 的答题卡和当前题号,用于刷新恢复体验;但交卷报告只以后端 `answer_records` 和 session 快照计算。
|
||||
- `answerText` 只保存学生作答或备注,不要为了让后端判对而把参考答案塞进去。
|
||||
- 重复答题时,报告会取同一题最新一条 `answer_records`,页面应以最近一次提交结果展示。
|
||||
|
||||
## 学生端支付与售后契约
|
||||
|
||||
学生端 `pages/student/checkout/index` 和 `pages/student/order-detail/index` 已接第一版。前端只传递套餐、地区、优惠券和支付 provider;最终金额、优惠抵扣、订单状态、支付记录、权益发放都以后端返回为准。
|
||||
|
||||
@@ -1065,6 +1065,20 @@ async function testCatalogAndLearning() {
|
||||
});
|
||||
assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false');
|
||||
|
||||
const selfJudgedObjective = await request('/api/learning/answers', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||
method: 'POST',
|
||||
body: {
|
||||
questionId: ids.question,
|
||||
selectedOptions: ['0'],
|
||||
selfJudgedCorrect: true,
|
||||
practiceSessionId: freeSession.item.id,
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(selfJudgedObjective.code, 'SELF_JUDGMENT_NOT_ALLOWED', 'objective questions must not accept self judgment');
|
||||
|
||||
const staffSequentialSession = await request('/api/learning/practice-sessions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
@@ -1128,6 +1142,19 @@ async function testCatalogAndLearning() {
|
||||
practiceSessionId: mockSession.item.id,
|
||||
},
|
||||
});
|
||||
const subjectiveAnswer = await request('/api/learning/answers', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
questionId: ids.questionThree,
|
||||
answerText: '已经对照参考答案完成自评',
|
||||
selfJudgedCorrect: true,
|
||||
practiceSessionId: mockSession.item.id,
|
||||
},
|
||||
});
|
||||
assert.equal(subjectiveAnswer.item?.isCorrect, true, 'subjective self judgment should be persisted by backend');
|
||||
assert.equal(subjectiveAnswer.item?.selfJudged, true, 'subjective answer response should mark self judgment');
|
||||
|
||||
const mockReport = await request('/api/learning/practice-sessions/submit', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -1139,14 +1166,18 @@ async function testCatalogAndLearning() {
|
||||
});
|
||||
assert.equal(mockReport.item?.practiceSessionId, mockSession.item.id, 'mock submit should create a report for the session');
|
||||
assert.equal(mockReport.item?.totalQuestions, 3, 'mock report should count session snapshot questions');
|
||||
assert.equal(mockReport.item?.answeredCount, 2, 'mock report should count latest submitted answers');
|
||||
assert.equal(mockReport.item?.correctCount, 1, 'mock report should count correct answers');
|
||||
assert.equal(mockReport.item?.answeredCount, 3, 'mock report should count latest submitted answers');
|
||||
assert.equal(mockReport.item?.correctCount, 2, 'mock report should count correct answers');
|
||||
assert.equal(mockReport.item?.wrongCount, 1, 'mock report should count wrong answers');
|
||||
assert.equal(mockReport.item?.unansweredCount, 1, 'mock report should count unanswered questions');
|
||||
assert.equal(mockReport.item?.score, 2, 'mock report should use backend scoring only');
|
||||
assert.equal(mockReport.item?.unansweredCount, 0, 'mock report should count unanswered questions');
|
||||
assert.equal(mockReport.item?.score, 4, 'mock report should use backend scoring only');
|
||||
assert.equal(mockReport.item?.totalScore, 100, 'mock report should keep configured paper total score');
|
||||
assert.ok(mockReport.item?.wrongQuestionIds?.includes(ids.questionTwo), 'mock report should include wrong question ids');
|
||||
assert.ok(mockReport.item?.questionResults?.some(item => item.questionId === ids.question && item.isCorrect === true), 'mock report should include per-question result');
|
||||
assert.ok(
|
||||
mockReport.item?.questionResults?.some(item => item.questionId === ids.questionThree && item.isCorrect === true && item.answerText === '已经对照参考答案完成自评'),
|
||||
'mock report should include backend-persisted subjective self judgment',
|
||||
);
|
||||
|
||||
const idempotentReport = await request('/api/learning/practice-sessions/submit', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
|
||||
@@ -986,7 +986,7 @@ async function main() {
|
||||
),
|
||||
(
|
||||
$4, $2, $5, $6, $7, $8, $9, $10,
|
||||
'smoke-question-3', 'choice', '单选题', 1, 'published',
|
||||
'smoke-question-3', 'short_answer', '简答题', 1, 'published',
|
||||
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
@@ -996,6 +996,10 @@ async function main() {
|
||||
entry_id = excluded.entry_id,
|
||||
content_node_id = excluded.content_node_id,
|
||||
primary_collection_id = excluded.primary_collection_id,
|
||||
type = excluded.type,
|
||||
type_label = excluded.type_label,
|
||||
difficulty = excluded.difficulty,
|
||||
status = excluded.status,
|
||||
exam_markers = excluded.exam_markers,
|
||||
updated_at = now()
|
||||
`,
|
||||
@@ -1021,7 +1025,7 @@ async function main() {
|
||||
values
|
||||
($1, $2, $3, 'choice', 1, 2, true, '{"source":"smoke-seed"}'::jsonb),
|
||||
($1, $2, $4, 'choice', 2, 2, true, '{"source":"smoke-seed"}'::jsonb),
|
||||
($1, $2, $5, 'choice', 3, 2, true, '{"source":"smoke-seed"}'::jsonb)
|
||||
($1, $2, $5, 'subjective', 3, 2, true, '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (tenant_id, collection_id, question_id)
|
||||
do update set section_key = excluded.section_key,
|
||||
sort_order = excluded.sort_order,
|
||||
@@ -1070,7 +1074,7 @@ async function main() {
|
||||
$3, $4, $5, $6, $7, $8,
|
||||
'smoke-mock-blueprint', '烟测全真模拟', 'mock_exam', 'collection', 10,
|
||||
120, 100, 60,
|
||||
'[{"key":"choice","title":"单选题","questionType":"choice","questionCount":10,"scoreEach":2}]'::jsonb,
|
||||
'[{"key":"choice","title":"单选题","questionType":"choice","questionCount":2,"scoreEach":2},{"key":"subjective","title":"主观题","questionType":"short_answer","questionCount":1,"scoreEach":2}]'::jsonb,
|
||||
'{"randomize":true,"showAnalysisAfterSubmit":false}'::jsonb,
|
||||
'active', 3, $9, $9
|
||||
)
|
||||
@@ -1118,9 +1122,9 @@ async function main() {
|
||||
1, '[1]'::jsonb, '4', '基础加法。'
|
||||
),
|
||||
(
|
||||
$6, $2, $7, 1, '3 + 3 = ?',
|
||||
'[{"label":"A","text":"5"},{"label":"B","text":"6"},{"label":"C","text":"7"}]'::jsonb,
|
||||
1, '[1]'::jsonb, '6', '基础加法。'
|
||||
$6, $2, $7, 1, '请简述多租户题库为什么必须以后端权限为准。',
|
||||
'[]'::jsonb,
|
||||
null, '[]'::jsonb, '后端需要统一校验租户、角色、资源和订单权益,前端只负责展示体验。', '主观题由学生查看参考答案后自评。'
|
||||
)
|
||||
on conflict (question_id, version_no)
|
||||
do update set content = excluded.content,
|
||||
|
||||
Reference in New Issue
Block a user