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,
|
||||
});
|
||||
}
|
||||
@@ -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:商用收费和运营能力
|
||||
|
||||
|
||||
@@ -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 租户后台页面
|
||||
|
||||
|
||||
@@ -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` 的远程等价测试。
|
||||
|
||||
@@ -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/*` 作为小程序登录适配层。
|
||||
|
||||
Reference in New Issue
Block a user