feat: add student learning flow pages

This commit is contained in:
Codex
2026-06-29 13:03:02 +08:00
parent 1bd608f134
commit 2776ce12a5
23 changed files with 681 additions and 18 deletions

View File

@@ -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}`);

View File

@@ -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 个人中心、会员、订单、签到、激活码、勋章
```
这些页面是联调骨架,不是最终视觉稿。后续应继续参照旧题库样式完善刷题交互、视频解析、题目反馈、模考报告、错题复习、订单收银台和小程序兼容。
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、题目反馈、视频解析、交卷报告、错题本和收藏夹第一版;后续应继续参照旧题库样式完善刷题细节、收银台、订单详情、售后入口和小程序兼容。
## 当前租户后台页面

View File

@@ -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',

View File

@@ -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: '院校趋势' },

View File

@@ -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>}

View File

@@ -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'>

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '选择地区',
});

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '练习报告',
});

View 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>
);
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '错题与收藏',
});

View 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>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '视频解析',
});

View 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>
);
}

View File

@@ -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 },
});
}

View File

@@ -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 } });
}

View File

@@ -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,
});
}

View 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,
});
}