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

View File

@@ -35,7 +35,7 @@
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、PocketBase JSON dry-run 报告 | 字段映射 UI、真实数据 dry-run 执行验收和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版 | 学生端完整体验、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析和练习/模考报告第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版 | 学生端收银台/订单详情/售后入口和刷题细节 UI、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
## 前端接入建议
@@ -79,7 +79,7 @@
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
- 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API平台后台关键写操作第一版已接入租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版;下一步补学生端完整刷题体验、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计增强和小程序兼容验证。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API学生端已接地区选择、错题/收藏复习、题目反馈、视频解析和练习/模考报告第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、导入问题、字段模板和复检第一版;下一步补学生端收银台/订单详情/售后入口和刷题细节 UI、租户后台导入上传/字段映射编辑/冲突处理详情、平台后台审计增强和小程序兼容验证。
### P1商用收费和运营能力

View File

@@ -25,10 +25,10 @@
## 当前可进入的前端工作
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、题库、练习、背单词、知识手册、分数线、资料、个人中心。
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、背单词、知识手册、分数线、资料、个人中心。
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、导入问题查看、模板预览和导入后复检的第一版操作能力。
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
- 可以继续复刻旧题库学生端主要视觉和交互:选地区、刷题细节、视频解析、题目反馈、模考报告、错题复习、商城收银台、订单详情、勋章展示
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、商城收银台、订单详情、售后入口、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习和收藏复习已经有第一版页面
- 可以按新后端主模型接入内容导航:
- `content_entries`
- `content_nodes`
@@ -64,15 +64,19 @@
| 启动页 | `apps/taro/src/pages/bootstrap/index.tsx` | `GET /api/tenant/resolve` |
| 短信登录 | `apps/taro/src/pages/student/login/index.tsx` | `POST /api/auth/sms/send``POST /api/auth/sms/verify` |
| 首页 | `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` |
| 练习 | `apps/taro/src/pages/student/practice/index.tsx` | `practice-sessions``questions``answers``favorites/questions``practice-sessions/submit``profile/feedbacks` |
| 错题/收藏 | `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` |
| 背单词 | `apps/taro/src/pages/student/vocabulary/index.tsx` | `vocabulary-units``vocabulary-words``vocabulary/review-plan``vocabulary/review` |
| 知识手册 | `apps/taro/src/pages/student/handbook/index.tsx` | `handbook-subjects``handbook-chapters``handbook-entries` |
| 分数线 | `apps/taro/src/pages/student/scoreline/index.tsx` | `scoreline/records` |
| 资料 | `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` |
当前页面主要用于打通接口和路由,后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化。
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题/收藏/反馈/视频 -> 交卷报告 -> 错题/收藏复习”,后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补收银台、订单详情和小程序分享/支付
## 已落地的 Taro 租户后台页面

View File

@@ -162,7 +162,7 @@
- H5 和小程序共用同一套业务 API client。
- 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;下一步补租户后台写操作、学生端完整体验、状态管理和小程序兼容验证。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、错题/收藏复习、题目反馈、视频解析和练习/模考报告第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;下一步补租户后台深度写操作、学生端收银台/订单详情、状态管理和小程序兼容验证。
### 第一批页面
@@ -210,7 +210,7 @@
## 推荐下一步顺序
1. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、订单收银台
1. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题已接第一版;继续补订单收银台、订单详情、售后入口和刷题细节 UI
2. 补租户后台写操作台:公共题库采纳/同步、冲突查看、导入问题、模板预览和导入后复检已接第一版;继续补导入上传 preview/import、字段映射编辑、冲突处理详情、角色模板、CRM/分佣。
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。

View File

@@ -1358,8 +1358,12 @@ ACTIVATION_CODE_REGION_MISMATCH
```text
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
@@ -1371,9 +1375,10 @@ pages/student/profile/index
```text
src/services/catalog.ts 目录、题库、单词、手册、分数线、资料
src/services/learning.ts 练习 session、答题、收藏、单词复习
src/services/learning.ts 练习 session、答题、收藏、错题/收藏复习、交卷报告、单词复习
src/services/commerce.ts 套餐、订单、权益、激活码
src/services/profile.ts 个人中心、签到、勋章、倒计时
src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时
src/services/video.ts 题目视频列表、播放签名
src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置、公共题库采纳/同步、导入复检
src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权
```
@@ -1391,7 +1396,7 @@ npm run build:taro:h5:platform
下一批前端开发重点:
- 学生端:地区、题目视频播放、题目反馈、错题/收藏专题页模考交卷报告订单收银台订单详情。
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页模考交卷报告已接第一版;下一批继续补订单收银台订单详情、售后入口、刷题细节 UI 和小程序支付/分享
- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、导入问题、模板预览和导入后复检第一版;下一批继续补完整写入表单、导入上传 preview/import 操作台、字段映射编辑、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。