forked from wangziqi/gongxue-base
feat: add student learning flow pages
This commit is contained in:
@@ -2,6 +2,15 @@ import type { RequestContext } from '../../core/http.js';
|
||||
import { query } from '../../core/db.js';
|
||||
import { intParam, tenantIdFrom } from '../../core/request.js';
|
||||
|
||||
function stringArrayParam(ctx: RequestContext, name: string, max = 200) {
|
||||
const repeated = ctx.url.searchParams.getAll(name);
|
||||
const csv = ctx.url.searchParams.get(name);
|
||||
const values = [...repeated, ...(csv ? csv.split(',') : [])]
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
return [...new Set(values)].slice(0, max);
|
||||
}
|
||||
|
||||
function dateOnly(value: unknown) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
|
||||
@@ -245,10 +254,15 @@ export async function questionsRoute(ctx: RequestContext) {
|
||||
const entryId = ctx.url.searchParams.get('entryId');
|
||||
const contentNodeId = ctx.url.searchParams.get('contentNodeId');
|
||||
const collectionId = ctx.url.searchParams.get('collectionId');
|
||||
const questionIds = stringArrayParam(ctx, 'questionIds', 300);
|
||||
const limit = intParam(ctx, 'limit', 200, 500);
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['q.tenant_id = $1', `q.status = 'published'`];
|
||||
if (questionIds.length) {
|
||||
params.push(questionIds);
|
||||
filters.push(`q.id = any($${params.length}::uuid[])`);
|
||||
}
|
||||
if (subjectId) {
|
||||
params.push(subjectId);
|
||||
filters.push(`q.subject_id = $${params.length}`);
|
||||
|
||||
@@ -28,8 +28,12 @@ apps/taro/dist/h5-platform-admin
|
||||
```text
|
||||
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 题目视频解析、播放签名
|
||||
pages/student/vocabulary/index 单词单元、复习计划、认识/再记、收藏
|
||||
pages/student/handbook/index 手册科目、章节、知识点阅读
|
||||
pages/student/scoreline/index 分数线列表
|
||||
@@ -37,7 +41,7 @@ pages/student/assets/index 资料列表、预览签名、下载签名
|
||||
pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章
|
||||
```
|
||||
|
||||
这些页面是联调骨架,不是最终视觉稿。后续应继续参照旧题库样式完善刷题交互、视频解析、题目反馈、模考报告、错题复习、订单收银台和小程序兼容。
|
||||
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、题目反馈、视频解析、交卷报告、错题本和收藏夹第一版;后续应继续参照旧题库样式完善刷题细节、收银台、订单详情、售后入口和小程序兼容。
|
||||
|
||||
## 当前租户后台页面
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ export default defineAppConfig({
|
||||
'pages/bootstrap/index',
|
||||
'pages/student/login/index',
|
||||
'pages/student/home/index',
|
||||
'pages/student/region/index',
|
||||
'pages/student/catalog/index',
|
||||
'pages/student/practice/index',
|
||||
'pages/student/review/index',
|
||||
'pages/student/reports/index',
|
||||
'pages/student/video/index',
|
||||
'pages/student/vocabulary/index',
|
||||
'pages/student/handbook/index',
|
||||
'pages/student/scoreline/index',
|
||||
|
||||
@@ -19,7 +19,10 @@ export default function StudentHomePage() {
|
||||
const entryNames = useMemo(() => (snapshot?.entries || []).slice(0, 6), [snapshot]);
|
||||
const brandName = tenant?.branding.brandName || tenant?.branding.shortName || '工学题库';
|
||||
const quickActions = [
|
||||
{ name: '选地区', path: '/pages/student/region/index', meta: '目标地区 / 权益' },
|
||||
{ name: '刷题', path: '/pages/student/catalog/index', meta: '顺序 / 随机 / 模考' },
|
||||
{ name: '错题收藏', path: '/pages/student/review/index', meta: '错题 / 收藏复习' },
|
||||
{ name: '练习报告', path: '/pages/student/reports/index', meta: '模考 / 历史' },
|
||||
{ name: '背单词', path: '/pages/student/vocabulary/index', meta: '复习计划' },
|
||||
{ name: '知识手册', path: '/pages/student/handbook/index', meta: '章节阅读' },
|
||||
{ name: '分数线', path: '/pages/student/scoreline/index', meta: '院校趋势' },
|
||||
|
||||
@@ -2,7 +2,16 @@ 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 {
|
||||
createPracticeSession,
|
||||
submitAnswer,
|
||||
submitPracticeSession,
|
||||
toggleQuestionFavorite,
|
||||
type PracticeReport,
|
||||
type PracticeSession,
|
||||
type QuestionItem,
|
||||
} from '@/services/learning';
|
||||
import { submitFeedback } from '@/services/profile';
|
||||
import '../student.css';
|
||||
|
||||
function plainOption(option: unknown, index: number) {
|
||||
@@ -22,7 +31,9 @@ export default function StudentPracticePage() {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [answerText, setAnswerText] = useState('');
|
||||
const [feedbackText, setFeedbackText] = useState('');
|
||||
const [resultByQuestion, setResultByQuestion] = useState<Record<string, boolean | null>>({});
|
||||
const [report, setReport] = useState<PracticeReport | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,7 +52,12 @@ export default function StudentPracticePage() {
|
||||
setSession(nextSession);
|
||||
const questionPayload = collectionId
|
||||
? await loadCollectionQuestions(collectionId, 300)
|
||||
: await loadQuestions({ entryId: body.entryId, contentNodeId: body.contentNodeId, limit: 300 });
|
||||
: await loadQuestions({
|
||||
entryId: body.entryId,
|
||||
contentNodeId: body.contentNodeId,
|
||||
questionIds: nextSession.questionIds,
|
||||
limit: Math.max(nextSession.questionIds?.length || 0, 50),
|
||||
});
|
||||
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 || []);
|
||||
@@ -76,6 +92,7 @@ export default function StudentPracticePage() {
|
||||
function nextQuestion() {
|
||||
setSelected([]);
|
||||
setAnswerText('');
|
||||
setFeedbackText('');
|
||||
setIndex(prev => Math.min(questions.length - 1, prev + 1));
|
||||
}
|
||||
|
||||
@@ -84,16 +101,74 @@ export default function StudentPracticePage() {
|
||||
await toggleQuestionFavorite(current.id, true).catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败'));
|
||||
}
|
||||
|
||||
async function handleFeedback() {
|
||||
if (!current || !feedbackText.trim()) {
|
||||
setError('请先填写反馈内容。');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await submitFeedback({
|
||||
questionId: current.id,
|
||||
type: 'question_error',
|
||||
title: '题目反馈',
|
||||
description: feedbackText.trim(),
|
||||
priority: 'normal',
|
||||
});
|
||||
setFeedbackText('');
|
||||
Taro.showToast({ title: '已提交反馈', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '反馈提交失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitSession() {
|
||||
if (!session) return;
|
||||
const ok = await Taro.showModal({
|
||||
title: '交卷',
|
||||
content: '确认交卷并生成练习报告?报告会按后端 session 快照评分。',
|
||||
confirmText: '交卷',
|
||||
cancelText: '取消',
|
||||
});
|
||||
if (!ok.confirm) return;
|
||||
try {
|
||||
const payload = await submitPracticeSession(session.id);
|
||||
setReport(payload.item || null);
|
||||
Taro.showToast({ title: '报告已生成', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '交卷失败');
|
||||
}
|
||||
}
|
||||
|
||||
function openReport() {
|
||||
if (!session) return;
|
||||
Taro.navigateTo({ url: `/pages/student/reports/index?practiceSessionId=${session.id}` });
|
||||
}
|
||||
|
||||
function openVideo() {
|
||||
if (!current) return;
|
||||
Taro.navigateTo({ url: `/pages/student/video/index?questionId=${current.id}` });
|
||||
}
|
||||
|
||||
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>
|
||||
<Text className='student-subtitle'>题目来自后端 session 快照,答题、交卷、错题和报告都以后端校验为准。</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{report ? (
|
||||
<View className='report-panel section-block'>
|
||||
<Text className='metric-value'>{String(report.score)} / {String(report.totalScore)}</Text>
|
||||
<Text className='metric-label'>正确率 {Math.round((report.accuracy || 0) * 100)}% · 已答 {report.answeredCount}/{report.totalQuestions}</Text>
|
||||
<View className='toolbar'>
|
||||
<Button className='primary-button' onClick={openReport}>查看报告</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{current ? (
|
||||
<View className='list-stack'>
|
||||
<View className='quiet-panel'>
|
||||
@@ -116,7 +191,16 @@ export default function StudentPracticePage() {
|
||||
<View className='toolbar'>
|
||||
<Button className='primary-button' onClick={handleSubmit}>提交</Button>
|
||||
<Button className='secondary-button' onClick={handleFavorite}>收藏</Button>
|
||||
<Button className='secondary-button' onClick={openVideo}>视频</Button>
|
||||
<Button className='secondary-button' onClick={nextQuestion}>下一题</Button>
|
||||
<Button className='secondary-button' onClick={handleSubmitSession}>交卷</Button>
|
||||
</View>
|
||||
<View className='quiet-panel'>
|
||||
<Text className='row-main'>题目反馈</Text>
|
||||
<Textarea className='textarea' placeholder='题干、答案、解析或视频存在问题,可以在这里提交给后台处理。' value={feedbackText} onInput={event => setFeedbackText(String(event.detail.value || ''))} />
|
||||
<View className='toolbar'>
|
||||
<Button className='secondary-button' onClick={handleFeedback}>提交反馈</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : <View className='empty-state'>暂无题目。请确认后台已发布题目并配置集合或练习蓝图。</View>}
|
||||
|
||||
@@ -98,6 +98,12 @@ export default function StudentProfilePage() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View className='toolbar wrap'>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/region/index' })}>切换地区</Button>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/review/index?type=wrong' })}>错题本</Button>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/review/index?type=favorite' })}>收藏夹</Button>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/reports/index' })}>练习报告</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
|
||||
3
apps/taro/src/pages/student/region/index.config.ts
Normal file
3
apps/taro/src/pages/student/region/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '选择地区',
|
||||
});
|
||||
69
apps/taro/src/pages/student/region/index.tsx
Normal file
69
apps/taro/src/pages/student/region/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { loadRegions, type RegionItem } from '@/services/catalog';
|
||||
import { loadProfile, updateProfile, type StudentProfile } from '@/services/profile';
|
||||
import '../student.css';
|
||||
|
||||
export default function StudentRegionPage() {
|
||||
const [regions, setRegions] = useState<RegionItem[]>([]);
|
||||
const [profile, setProfile] = useState<StudentProfile | null>(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function reload() {
|
||||
Promise.all([
|
||||
loadRegions().catch(() => ({ items: [] })),
|
||||
loadProfile().catch(() => ({ item: null })),
|
||||
]).then(([regionPayload, profilePayload]) => {
|
||||
setRegions(regionPayload.items || []);
|
||||
setProfile(profilePayload.item || null);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '地区加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
async function selectRegion(item: RegionItem) {
|
||||
setBusy(item.id);
|
||||
setError('');
|
||||
try {
|
||||
await updateProfile({ regionId: item.id });
|
||||
Taro.showToast({ title: '已切换地区', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '地区保存失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
const currentRegionId = profile?.target?.regionId;
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Region</Text>
|
||||
<Text className='student-title'>选择地区</Text>
|
||||
<Text className='student-subtitle'>地区会影响题库入口、分数线、资料、考试倒计时和会员权益范围。</Text>
|
||||
</View>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateBack()}>返回</Button>
|
||||
</View>
|
||||
|
||||
<View className='list-stack'>
|
||||
{regions.map(item => (
|
||||
<View className={`list-row ${item.id === currentRegionId ? 'active' : ''}`} key={item.id} onClick={() => selectRegion(item)}>
|
||||
<Text className='row-main'>{item.name}{item.isHot ? ' · 热门' : ''}</Text>
|
||||
<Text className='row-meta'>{item.shortName || item.code || '地区'}{item.id === currentRegionId ? ' · 当前选择' : ''}</Text>
|
||||
{busy === item.id ? <Text className='row-meta'>保存中...</Text> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!regions.length ? <View className='empty-state'>当前租户暂无可选地区,请联系后台管理员配置地区和题库入口。</View> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/reports/index.config.ts
Normal file
3
apps/taro/src/pages/student/reports/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '练习报告',
|
||||
});
|
||||
85
apps/taro/src/pages/student/reports/index.tsx
Normal file
85
apps/taro/src/pages/student/reports/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { loadPracticeReports, loadPracticeSessionReport, type PracticeReport } from '@/services/learning';
|
||||
import '../student.css';
|
||||
|
||||
function percent(value?: number) {
|
||||
return `${Math.round((value || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
export default function StudentReportsPage() {
|
||||
const router = useRouter();
|
||||
const practiceSessionId = router.params?.practiceSessionId || '';
|
||||
const [report, setReport] = useState<PracticeReport | null>(null);
|
||||
const [reports, setReports] = useState<PracticeReport[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (practiceSessionId) {
|
||||
loadPracticeSessionReport(practiceSessionId)
|
||||
.then(payload => setReport(payload.item || null))
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '报告加载失败'));
|
||||
return;
|
||||
}
|
||||
loadPracticeReports({ limit: 20 })
|
||||
.then(payload => setReports(payload.items || []))
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '报告列表加载失败'));
|
||||
}, [practiceSessionId]);
|
||||
|
||||
const current = report || reports[0] || null;
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Report</Text>
|
||||
<Text className='student-title'>练习报告</Text>
|
||||
<Text className='student-subtitle'>分数、正确率、题型分段和错题明细均以后端 session 快照评分为准。</Text>
|
||||
</View>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateBack()}>返回</Button>
|
||||
</View>
|
||||
|
||||
{current ? (
|
||||
<View className='report-panel'>
|
||||
<Text className='metric-value'>{String(current.score)} / {String(current.totalScore)}</Text>
|
||||
<Text className='metric-label'>{current.mode} · 正确率 {percent(current.accuracy)} · 已答 {current.answeredCount}/{current.totalQuestions}</Text>
|
||||
<View className='grid-two section-block'>
|
||||
<View className='metric'><Text className='metric-value'>{String(current.correctCount)}</Text><Text className='metric-label'>正确</Text></View>
|
||||
<View className='metric'><Text className='metric-value'>{String(current.wrongCount)}</Text><Text className='metric-label'>错误</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
) : <View className='empty-state'>暂无练习报告,完成一次练习后会在这里展示。</View>}
|
||||
|
||||
{current?.sectionStats?.length ? (
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>分段统计</Text>
|
||||
<View className='list-stack'>
|
||||
{current.sectionStats.map((section, index) => (
|
||||
<View className='list-row' key={String(section.key || index)}>
|
||||
<Text className='row-main'>{String(section.title || section.key || '分段')}</Text>
|
||||
<Text className='row-meta'>得分 {String(section.score || 0)} / {String(section.totalScore || 0)} · 正确 {String(section.correctCount || 0)} / {String(section.questionCount || 0)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!practiceSessionId && reports.length > 1 ? (
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>历史报告</Text>
|
||||
<View className='list-stack'>
|
||||
{reports.slice(1).map(item => (
|
||||
<View className='list-row' key={item.id} onClick={() => setReport(item)}>
|
||||
<Text className='row-main'>{item.mode} · {String(item.score)} / {String(item.totalScore)}</Text>
|
||||
<Text className='row-meta'>正确率 {percent(item.accuracy)} · {item.submittedAt ? item.submittedAt.slice(0, 10) : ''}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/review/index.config.ts
Normal file
3
apps/taro/src/pages/student/review/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '错题与收藏',
|
||||
});
|
||||
102
apps/taro/src/pages/student/review/index.tsx
Normal file
102
apps/taro/src/pages/student/review/index.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import {
|
||||
loadFavoriteQuestions,
|
||||
loadWrongQuestionReviewPlan,
|
||||
resolveWrongQuestion,
|
||||
type QuestionItem,
|
||||
type WrongQuestionReviewItem,
|
||||
} from '@/services/learning';
|
||||
import '../student.css';
|
||||
|
||||
type ReviewMode = 'wrong' | 'favorite';
|
||||
|
||||
function questionIdOf(item: QuestionItem | WrongQuestionReviewItem) {
|
||||
return 'questionId' in item && item.questionId ? item.questionId : (item as QuestionItem).id;
|
||||
}
|
||||
|
||||
export default function StudentReviewPage() {
|
||||
const router = useRouter();
|
||||
const initialMode = router.params?.type === 'favorite' ? 'favorite' : 'wrong';
|
||||
const [mode, setMode] = useState<ReviewMode>(initialMode);
|
||||
const [wrongItems, setWrongItems] = useState<WrongQuestionReviewItem[]>([]);
|
||||
const [favoriteItems, setFavoriteItems] = useState<QuestionItem[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function reload(nextMode = mode) {
|
||||
setError('');
|
||||
if (nextMode === 'wrong') {
|
||||
loadWrongQuestionReviewPlan(30)
|
||||
.then(payload => setWrongItems(payload.items || []))
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '错题加载失败'));
|
||||
return;
|
||||
}
|
||||
loadFavoriteQuestions(50)
|
||||
.then(payload => setFavoriteItems(payload.items || []))
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload(mode);
|
||||
}, [mode]);
|
||||
|
||||
function startReview() {
|
||||
const reviewMode = mode === 'wrong' ? 'wrong_review' : 'favorite_review';
|
||||
Taro.navigateTo({ url: `/pages/student/practice/index?mode=${reviewMode}&questionLimit=30` });
|
||||
}
|
||||
|
||||
async function markResolved(questionId: string) {
|
||||
try {
|
||||
await resolveWrongQuestion(questionId);
|
||||
Taro.showToast({ title: '已移出错题', icon: 'success' });
|
||||
reload('wrong');
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '移出错题失败');
|
||||
}
|
||||
}
|
||||
|
||||
const items = mode === 'wrong' ? wrongItems : favoriteItems;
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Review</Text>
|
||||
<Text className='student-title'>{mode === 'wrong' ? '错题本' : '收藏夹'}</Text>
|
||||
<Text className='student-subtitle'>复习组卷由后端按当前用户错题/收藏安全生成,前端不传题目列表。</Text>
|
||||
</View>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateBack()}>返回</Button>
|
||||
</View>
|
||||
|
||||
<View className='toolbar wrap'>
|
||||
<Button className={`pill-button ${mode === 'wrong' ? 'active' : ''}`} onClick={() => setMode('wrong')}>错题</Button>
|
||||
<Button className={`pill-button ${mode === 'favorite' ? 'active' : ''}`} onClick={() => setMode('favorite')}>收藏</Button>
|
||||
<Button className='primary-button' onClick={startReview}>开始复习</Button>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>{mode === 'wrong' ? '建议复习' : '收藏题目'}</Text>
|
||||
<View className='list-stack'>
|
||||
{items.map(item => {
|
||||
const questionId = questionIdOf(item);
|
||||
return (
|
||||
<View className='list-row' key={questionId}>
|
||||
<Text className='row-main'>{item.content || '未提供题干'}</Text>
|
||||
<Text className='row-meta'>{item.typeLabel || item.type || '题目'}{mode === 'wrong' ? ` · 错 ${String((item as WrongQuestionReviewItem).wrongCount || 0)} 次` : ''}</Text>
|
||||
{mode === 'wrong' ? (
|
||||
<View className='toolbar'>
|
||||
<Button className='secondary-button' onClick={() => markResolved(questionId)}>移出错题</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{!items.length ? <View className='empty-state'>{mode === 'wrong' ? '暂无待复习错题。' : '暂无收藏题目。'}</View> : null}
|
||||
</View>
|
||||
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,11 @@
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.toolbar.wrap {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.pill-button,
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
@@ -84,6 +89,12 @@
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.pill-button.warn {
|
||||
border-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-top: 30px;
|
||||
}
|
||||
@@ -103,6 +114,13 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.report-panel {
|
||||
padding: 28px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.list-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -116,6 +134,11 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.list-row.active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: block;
|
||||
color: #111827;
|
||||
@@ -211,3 +234,11 @@
|
||||
.muted-text {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.video-frame {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
3
apps/taro/src/pages/student/video/index.config.ts
Normal file
3
apps/taro/src/pages/student/video/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '视频解析',
|
||||
});
|
||||
74
apps/taro/src/pages/student/video/index.tsx
Normal file
74
apps/taro/src/pages/student/video/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { Button, Text, View, Video } from '@tarojs/components';
|
||||
import { loadQuestionVideos, playVideo, type QuestionVideoItem, type VideoPlayback } from '@/services/video';
|
||||
import '../student.css';
|
||||
|
||||
export default function StudentVideoPage() {
|
||||
const router = useRouter();
|
||||
const questionId = router.params?.questionId || '';
|
||||
const [videos, setVideos] = useState<QuestionVideoItem[]>([]);
|
||||
const [selected, setSelected] = useState<QuestionVideoItem | null>(null);
|
||||
const [playback, setPlayback] = useState<VideoPlayback | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!questionId) {
|
||||
setError('缺少题目 ID,无法加载视频解析。');
|
||||
return;
|
||||
}
|
||||
loadQuestionVideos(questionId)
|
||||
.then(payload => {
|
||||
const nextVideos = payload.videos || [];
|
||||
setVideos(nextVideos);
|
||||
setSelected(nextVideos[0] || null);
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '视频加载失败'));
|
||||
}, [questionId]);
|
||||
|
||||
async function startPlay(item = selected) {
|
||||
if (!item) return;
|
||||
setError('');
|
||||
try {
|
||||
const payload = await playVideo({ videoId: item.id, questionId });
|
||||
setSelected(item);
|
||||
setPlayback(payload);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '播放授权失败');
|
||||
}
|
||||
}
|
||||
|
||||
const playUrl = playback?.playback?.url;
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Video</Text>
|
||||
<Text className='student-title'>视频解析</Text>
|
||||
<Text className='student-subtitle'>播放地址由后端按会员权益或视频次数签名生成,前端不保存私有资源 URL。</Text>
|
||||
</View>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateBack()}>返回</Button>
|
||||
</View>
|
||||
|
||||
{playUrl ? (
|
||||
<Video className='video-frame' src={playUrl} controls autoplay />
|
||||
) : <View className='empty-state'>选择视频后获取播放授权。</View>}
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>可用视频</Text>
|
||||
<View className='list-stack'>
|
||||
{videos.map(item => (
|
||||
<View className={`list-row ${selected?.id === item.id ? 'active' : ''}`} key={item.id} onClick={() => startPlay(item)}>
|
||||
<Text className='row-main'>{item.title}</Text>
|
||||
<Text className='row-meta'>{item.accessMode || 'svip'} · {item.duration ? `${item.duration} 秒` : '未标注时长'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!videos.length ? <View className='empty-state'>这道题暂未绑定视频解析。</View> : null}
|
||||
</View>
|
||||
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -149,9 +149,9 @@ export async function loadCollectionQuestions(collectionId: string, limit = 200)
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadQuestions(query: { entryId?: string; contentNodeId?: string; collectionId?: string; limit?: number }) {
|
||||
export async function loadQuestions(query: { entryId?: string; contentNodeId?: string; collectionId?: string; questionIds?: string[]; limit?: number }) {
|
||||
return apiRequest<{ items?: import('./learning').QuestionItem[] }>('/api/catalog/questions', {
|
||||
query: { ...query, limit: query.limit || 200 },
|
||||
query: { ...query, questionIds: query.questionIds?.join(','), limit: query.limit || 200 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface QuestionOption {
|
||||
|
||||
export interface QuestionItem {
|
||||
id: string;
|
||||
questionId?: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
typeLabel?: string;
|
||||
@@ -19,6 +20,10 @@ export interface QuestionItem {
|
||||
correctOptionIndices?: number[] | null;
|
||||
explanation?: string | null;
|
||||
hasVideoExplanation?: boolean;
|
||||
wrongCount?: number;
|
||||
lastWrongAt?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface PracticeSession {
|
||||
@@ -38,6 +43,58 @@ export interface AnswerResult {
|
||||
isCorrect: boolean | null;
|
||||
}
|
||||
|
||||
export interface PracticeReport {
|
||||
id: string;
|
||||
practiceSessionId: string;
|
||||
mode: string;
|
||||
totalQuestions: number;
|
||||
answeredCount: number;
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
unansweredCount: number;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
accuracy: number;
|
||||
durationSeconds?: number;
|
||||
sectionStats?: Record<string, unknown>[];
|
||||
questionResults?: Record<string, unknown>[];
|
||||
wrongQuestionIds?: string[];
|
||||
submittedAt?: string;
|
||||
}
|
||||
|
||||
export interface PracticeHistoryItem {
|
||||
id: string;
|
||||
mode: string;
|
||||
collectionName?: string | null;
|
||||
entryName?: string | null;
|
||||
contentNodeName?: string | null;
|
||||
questionCount?: number;
|
||||
answeredCount?: number;
|
||||
correctCount?: number;
|
||||
wrongCount?: number;
|
||||
status?: string;
|
||||
reportId?: string | null;
|
||||
score?: number | null;
|
||||
accuracy?: number | null;
|
||||
startedAt?: string;
|
||||
finishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface WrongQuestionReviewItem {
|
||||
questionId: string;
|
||||
wrongCount?: number;
|
||||
lastWrongAt?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
type?: string;
|
||||
typeLabel?: string | null;
|
||||
subjectName?: string | null;
|
||||
categoryName?: string | null;
|
||||
content?: string | null;
|
||||
answerAttempts?: number;
|
||||
reviewStatus?: string;
|
||||
suggestedReviewAt?: string | null;
|
||||
}
|
||||
|
||||
export interface VocabularyWord {
|
||||
id?: string;
|
||||
wordId?: string;
|
||||
@@ -78,6 +135,31 @@ export async function submitAnswer(body: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitPracticeSession(practiceSessionId: string) {
|
||||
return apiRequest<{ item?: PracticeReport }>('/api/learning/practice-sessions/submit', {
|
||||
method: 'POST',
|
||||
body: { practiceSessionId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPracticeSessionReport(practiceSessionId: string) {
|
||||
return apiRequest<{ item?: PracticeReport }>('/api/learning/practice-sessions/report', {
|
||||
query: { practiceSessionId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPracticeReports(query: { mode?: string; blueprintId?: string; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: PracticeReport[] }>('/api/learning/practice-reports', {
|
||||
query: { ...query, limit: query.limit || 20 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPracticeHistory(query: { mode?: string; status?: string; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: PracticeHistoryItem[] }>('/api/learning/practice-sessions/history', {
|
||||
query: { ...query, limit: query.limit || 20 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleQuestionFavorite(questionId: string, favorite: boolean) {
|
||||
return apiRequest<{ ok: boolean; favorite: boolean }>('/api/learning/favorites/questions', {
|
||||
method: 'POST',
|
||||
@@ -89,6 +171,19 @@ export async function loadWrongQuestions(limit = 30) {
|
||||
return apiRequest<{ items?: QuestionItem[] }>('/api/learning/wrong-questions', { query: { limit } });
|
||||
}
|
||||
|
||||
export async function loadWrongQuestionReviewPlan(limit = 20) {
|
||||
return apiRequest<{ items?: WrongQuestionReviewItem[]; nextAction?: Record<string, unknown> }>('/api/learning/wrong-questions/review-plan', {
|
||||
query: { limit },
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveWrongQuestion(questionId: string) {
|
||||
return apiRequest<{ ok: boolean }>('/api/learning/wrong-questions/resolve', {
|
||||
method: 'POST',
|
||||
body: { questionId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadFavoriteQuestions(limit = 30) {
|
||||
return apiRequest<{ items?: QuestionItem[] }>('/api/learning/favorites/questions', { query: { limit } });
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ export interface StudentProfile {
|
||||
phone?: string | null;
|
||||
score?: number;
|
||||
target?: {
|
||||
regionId?: string | null;
|
||||
regionName?: string | null;
|
||||
schoolId?: string | null;
|
||||
schoolName?: string | null;
|
||||
majorId?: string | null;
|
||||
majorName?: string | null;
|
||||
};
|
||||
membership?: {
|
||||
@@ -22,6 +25,19 @@ export async function loadProfile() {
|
||||
return apiRequest<{ item?: StudentProfile }>('/api/profile/me');
|
||||
}
|
||||
|
||||
export async function updateProfile(input: {
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
regionId?: string;
|
||||
selectedSchoolId?: string;
|
||||
selectedMajorId?: string;
|
||||
}) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/profile/me', {
|
||||
method: 'PATCH',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkIn() {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/profile/check-in', { method: 'POST' });
|
||||
}
|
||||
@@ -33,3 +49,20 @@ export async function loadBadges() {
|
||||
export async function loadExamCountdowns() {
|
||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/profile/exam-countdowns');
|
||||
}
|
||||
|
||||
export async function loadFeedbacks(limit = 20) {
|
||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/profile/feedbacks', { query: { limit } });
|
||||
}
|
||||
|
||||
export async function submitFeedback(input: {
|
||||
questionId?: string;
|
||||
type?: 'question_error' | 'content_error' | 'video_error' | 'asset_error' | 'system_bug' | 'suggestion' | 'other';
|
||||
title?: string;
|
||||
description: string;
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
||||
}) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/profile/feedbacks', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
38
apps/taro/src/services/video.ts
Normal file
38
apps/taro/src/services/video.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export interface QuestionVideoItem {
|
||||
id: string;
|
||||
questionId?: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
duration?: number | null;
|
||||
accessMode?: string;
|
||||
freePreviewSeconds?: number;
|
||||
playable?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoPlayback {
|
||||
item?: QuestionVideoItem;
|
||||
playToken?: string;
|
||||
playback?: {
|
||||
url?: string;
|
||||
expiresAt?: string;
|
||||
expiresInSec?: number;
|
||||
signatureMode?: string;
|
||||
};
|
||||
access?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function loadQuestionVideos(questionId: string) {
|
||||
return apiRequest<{ videos?: QuestionVideoItem[]; total?: number }>('/api/questions/videos', {
|
||||
query: { questionId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function playVideo(input: { videoId: string; questionId?: string }) {
|
||||
return apiRequest<VideoPlayback>('/api/videos/play', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user