forked from wangziqi/gongxue-base
feat: add taro student learning flow
This commit is contained in:
18
README.md
18
README.md
@@ -33,7 +33,7 @@
|
||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||
- 题库导出目前完成服务端结构化 payload;PDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。
|
||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;完整页面和端到端联调仍需继续补。
|
||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心,租户后台/平台后台完整页面和端到端联调仍需继续补。
|
||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||
|
||||
更完整的进度看这些文档:
|
||||
@@ -117,6 +117,20 @@ apps/taro/dist/h5-platform-admin
|
||||
|
||||
推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。
|
||||
|
||||
学生端当前页面:
|
||||
|
||||
```text
|
||||
apps/taro/src/pages/student/login
|
||||
apps/taro/src/pages/student/home
|
||||
apps/taro/src/pages/student/catalog
|
||||
apps/taro/src/pages/student/practice
|
||||
apps/taro/src/pages/student/vocabulary
|
||||
apps/taro/src/pages/student/handbook
|
||||
apps/taro/src/pages/student/scoreline
|
||||
apps/taro/src/pages/student/assets
|
||||
apps/taro/src/pages/student/profile
|
||||
```
|
||||
|
||||
单次运行 CRM worker:
|
||||
|
||||
```bash
|
||||
@@ -299,7 +313,7 @@ git diff --check
|
||||
优先继续补:
|
||||
|
||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||
2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。
|
||||
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台和平台后台完整业务页面,小程序兼容验证。
|
||||
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
|
||||
4. 题库导出 PDF/Word worker、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Taro 前端工程
|
||||
|
||||
这是 SaaS 题库的新跨端前端地基。当前阶段先提供 H5 多入口壳、租户解析、统一 API client、Supabase Auth client 初始化和安全环境变量边界。
|
||||
这是 SaaS 题库的新跨端前端地基。当前阶段已经提供 H5 多入口、租户解析、统一 API client、Supabase Auth client 初始化、安全环境变量边界,以及学生端第一批可联调页面。
|
||||
|
||||
## 三个 H5 入口
|
||||
|
||||
@@ -21,6 +21,24 @@ apps/taro/dist/h5-platform-admin
|
||||
|
||||
可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`。
|
||||
|
||||
## 当前学生端页面
|
||||
|
||||
已接入第一批真实后端 API:
|
||||
|
||||
```text
|
||||
pages/student/login/index 短信登录
|
||||
pages/student/home/index 首页与功能入口
|
||||
pages/student/catalog/index 题库入口、分类、集合、练习蓝图
|
||||
pages/student/practice/index 创建练习 session、答题、收藏
|
||||
pages/student/vocabulary/index 单词单元、复习计划、认识/再记、收藏
|
||||
pages/student/handbook/index 手册科目、章节、知识点阅读
|
||||
pages/student/scoreline/index 分数线列表
|
||||
pages/student/assets/index 资料列表、预览签名、下载签名
|
||||
pages/student/profile/index 个人中心、会员、订单、签到、激活码、勋章
|
||||
```
|
||||
|
||||
这些页面是联调骨架,不是最终视觉稿。后续应继续参照旧题库样式完善刷题交互、视频解析、题目反馈、模考报告、错题复习、订单收银台和小程序兼容。
|
||||
|
||||
## 前端环境变量
|
||||
|
||||
只允许使用:
|
||||
@@ -42,3 +60,15 @@ TARO_APP_TENANT_CODE=<可选,小程序/预览环境使用>
|
||||
- `x-tenant-id` 只是租户上下文,不是身份凭证。
|
||||
- 登录后不要传 `x-user-id` 或 body/query `userId` 伪造当前用户。
|
||||
- 题库练习、订单支付、内容导入、CRM、资料签名、后台配置必须走后端命令层。
|
||||
|
||||
## H5 部署建议
|
||||
|
||||
当前保留三个独立构建目录,适合分别放到三个域名或同一服务器的三个静态目录:
|
||||
|
||||
```text
|
||||
student.example.com -> apps/taro/dist/h5-student
|
||||
tenant-admin.example.com -> apps/taro/dist/h5-tenant-admin
|
||||
platform-admin.example.com -> apps/taro/dist/h5-platform-admin
|
||||
```
|
||||
|
||||
API 使用 `TARO_APP_API_BASE_URL` 指向统一后端。学生端通过域名解析租户;小程序或预览环境可用 `TARO_APP_TENANT_CODE` 指定租户。
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/bootstrap/index',
|
||||
'pages/student/login/index',
|
||||
'pages/student/home/index',
|
||||
'pages/student/catalog/index',
|
||||
'pages/student/practice/index',
|
||||
'pages/student/vocabulary/index',
|
||||
'pages/student/handbook/index',
|
||||
'pages/student/scoreline/index',
|
||||
'pages/student/assets/index',
|
||||
'pages/student/profile/index',
|
||||
'pages/tenant-admin/workbench/index',
|
||||
'pages/platform-admin/workbench/index',
|
||||
],
|
||||
|
||||
3
apps/taro/src/pages/student/assets/index.config.ts
Normal file
3
apps/taro/src/pages/student/assets/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '资料',
|
||||
});
|
||||
70
apps/taro/src/pages/student/assets/index.tsx
Normal file
70
apps/taro/src/pages/student/assets/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { loadContentAssets, signAssetDownload, signAssetPreview, type ContentAsset } from '@/services/catalog';
|
||||
import '../student.css';
|
||||
|
||||
function openUrl(url?: string) {
|
||||
if (!url) return;
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
Taro.setClipboardData({ data: url });
|
||||
}
|
||||
|
||||
export default function StudentAssetsPage() {
|
||||
const [assets, setAssets] = useState<ContentAsset[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadContentAssets({ includeLocked: true })
|
||||
.then(payload => setAssets(payload.items || []))
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '资料加载失败'));
|
||||
}, []);
|
||||
|
||||
async function preview(asset: ContentAsset) {
|
||||
try {
|
||||
const payload = await signAssetPreview(asset.id);
|
||||
openUrl(payload.preview?.url);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '预览失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function download(asset: ContentAsset) {
|
||||
try {
|
||||
const payload = await signAssetDownload(asset.id);
|
||||
openUrl(payload.download?.url);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '下载失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Assets</Text>
|
||||
<Text className='student-title'>资料下载</Text>
|
||||
<Text className='student-subtitle'>私有文件必须向后端申请短期签名,前端不直接拼对象存储地址。</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='list-stack'>
|
||||
{assets.map(item => (
|
||||
<View className='list-row' key={item.id}>
|
||||
<Text className='row-main'>{item.title || item.fileName || '未命名资料'}</Text>
|
||||
<Text className='row-meta'>{item.assetType || 'asset'} · {item.visibility || 'public'}</Text>
|
||||
{item.description ? <Text className='row-meta'>{item.description}</Text> : null}
|
||||
<View className='toolbar'>
|
||||
<Button className='secondary-button' onClick={() => preview(item)}>预览</Button>
|
||||
<Button className='primary-button' onClick={() => download(item)}>下载</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!assets.length ? <View className='empty-state'>暂无可见资料。</View> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/catalog/index.config.ts
Normal file
3
apps/taro/src/pages/student/catalog/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '题库',
|
||||
});
|
||||
134
apps/taro/src/pages/student/catalog/index.tsx
Normal file
134
apps/taro/src/pages/student/catalog/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import {
|
||||
loadContentEntries,
|
||||
loadContentNodes,
|
||||
loadPracticeBlueprints,
|
||||
loadQuestionCollections,
|
||||
type ContentEntry,
|
||||
type ContentNode,
|
||||
type PracticeBlueprint,
|
||||
type QuestionCollection,
|
||||
} from '@/services/catalog';
|
||||
import '../student.css';
|
||||
|
||||
export default function StudentCatalogPage() {
|
||||
const router = useRouter();
|
||||
const query = router.params || {};
|
||||
const [entries, setEntries] = useState<ContentEntry[]>([]);
|
||||
const [selectedEntryId, setSelectedEntryId] = useState(query.entryId || '');
|
||||
const [nodes, setNodes] = useState<ContentNode[]>([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(query.nodeId || '');
|
||||
const [collections, setCollections] = useState<QuestionCollection[]>([]);
|
||||
const [blueprints, setBlueprints] = useState<PracticeBlueprint[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadContentEntries(undefined, 'question_bank')
|
||||
.then(payload => {
|
||||
const nextEntries = payload.items || [];
|
||||
setEntries(nextEntries);
|
||||
if (!selectedEntryId && nextEntries[0]) setSelectedEntryId(nextEntries[0].id);
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '题库入口加载失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntryId) return;
|
||||
setSelectedNodeId('');
|
||||
loadContentNodes(selectedEntryId, 'root', 'children')
|
||||
.then(payload => setNodes(payload.items || []))
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '分类加载失败'));
|
||||
loadQuestionCollections({ entryId: selectedEntryId })
|
||||
.then(payload => setCollections(payload.items || []))
|
||||
.catch(() => setCollections([]));
|
||||
loadPracticeBlueprints({ entryId: selectedEntryId })
|
||||
.then(payload => setBlueprints(payload.items || []))
|
||||
.catch(() => setBlueprints([]));
|
||||
}, [selectedEntryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNodeId) return;
|
||||
Promise.all([
|
||||
loadQuestionCollections({ entryId: selectedEntryId, nodeId: selectedNodeId }),
|
||||
loadPracticeBlueprints({ entryId: selectedEntryId, nodeId: selectedNodeId }),
|
||||
]).then(([nextCollections, nextBlueprints]) => {
|
||||
setCollections(nextCollections.items || []);
|
||||
setBlueprints(nextBlueprints.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '组卷配置加载失败'));
|
||||
}, [selectedNodeId]);
|
||||
|
||||
const selectedEntry = useMemo(() => entries.find(item => item.id === selectedEntryId), [entries, selectedEntryId]);
|
||||
|
||||
function startPractice(input: { collection?: QuestionCollection; blueprint?: PracticeBlueprint; mode?: string }) {
|
||||
const params = [
|
||||
input.collection ? `collectionId=${encodeURIComponent(input.collection.id)}` : '',
|
||||
input.blueprint ? `blueprintId=${encodeURIComponent(input.blueprint.id)}` : '',
|
||||
selectedEntryId ? `entryId=${encodeURIComponent(selectedEntryId)}` : '',
|
||||
selectedNodeId ? `contentNodeId=${encodeURIComponent(selectedNodeId)}` : '',
|
||||
`mode=${encodeURIComponent(input.mode || input.blueprint?.mode || 'sequential')}`,
|
||||
].filter(Boolean).join('&');
|
||||
Taro.navigateTo({ url: `/pages/student/practice/index?${params}` });
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Question Bank</Text>
|
||||
<Text className='student-title'>题库练习</Text>
|
||||
<Text className='student-subtitle'>{selectedEntry?.description || '按入口、分类、集合和蓝图进入练习,分类层级不在前端写死。'}</Text>
|
||||
</View>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateBack()}>
|
||||
返回
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>入口</Text>
|
||||
<View className='toolbar'>
|
||||
{entries.map(item => (
|
||||
<Button key={item.id} className={`pill-button ${item.id === selectedEntryId ? 'active' : ''}`} onClick={() => setSelectedEntryId(item.id)}>
|
||||
{item.name}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>分类</Text>
|
||||
{nodes.length ? (
|
||||
<View className='list-stack'>
|
||||
{nodes.map(item => (
|
||||
<View className='list-row' key={item.id} onClick={() => setSelectedNodeId(item.id)}>
|
||||
<Text className='row-main'>{item.name}</Text>
|
||||
<Text className='row-meta'>{item.markerType || item.nodeType || '分类'}{item.isLeaf ? ' · 可练习' : ''}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : <View className='empty-state'>暂无分类,导入题库后会在这里展示。</View>}
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>练习方式</Text>
|
||||
<View className='grid-two'>
|
||||
{blueprints.map(item => (
|
||||
<View className='metric' key={item.id} onClick={() => startPractice({ blueprint: item })}>
|
||||
<Text className='metric-value'>{item.name}</Text>
|
||||
<Text className='metric-label'>{item.mode} · {item.questionLimit || '不限'} 题</Text>
|
||||
</View>
|
||||
))}
|
||||
{collections.map(item => (
|
||||
<View className='metric' key={item.id} onClick={() => startPractice({ collection: item, mode: item.collectionType === 'paper' ? 'mock_exam' : 'sequential' })}>
|
||||
<Text className='metric-value'>{item.name}</Text>
|
||||
<Text className='metric-label'>{item.collectionType || 'collection'} · {item.questionCount || 0} 题</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!collections.length && !blueprints.length ? <View className='empty-state'>当前入口还没有可用集合或练习蓝图。</View> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/handbook/index.config.ts
Normal file
3
apps/taro/src/pages/student/handbook/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '知识手册',
|
||||
});
|
||||
91
apps/taro/src/pages/student/handbook/index.tsx
Normal file
91
apps/taro/src/pages/student/handbook/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, View } from '@tarojs/components';
|
||||
import { loadHandbookChapters, loadHandbookEntries, loadHandbookSubjects, type HandbookChapter, type HandbookEntry, type HandbookSubject } from '@/services/catalog';
|
||||
import '../student.css';
|
||||
|
||||
export default function StudentHandbookPage() {
|
||||
const [subjects, setSubjects] = useState<HandbookSubject[]>([]);
|
||||
const [chapters, setChapters] = useState<HandbookChapter[]>([]);
|
||||
const [entries, setEntries] = useState<HandbookEntry[]>([]);
|
||||
const [subjectId, setSubjectId] = useState('');
|
||||
const [chapterId, setChapterId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadHandbookSubjects()
|
||||
.then(payload => {
|
||||
const nextSubjects = payload.items || [];
|
||||
setSubjects(nextSubjects);
|
||||
if (nextSubjects[0]) setSubjectId(nextSubjects[0].id);
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '知识手册加载失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subjectId) return;
|
||||
setEntries([]);
|
||||
loadHandbookChapters(subjectId)
|
||||
.then(payload => {
|
||||
const nextChapters = payload.items || [];
|
||||
setChapters(nextChapters);
|
||||
setChapterId(nextChapters[0]?.id || '');
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '章节加载失败'));
|
||||
}, [subjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterId) return;
|
||||
loadHandbookEntries(chapterId, true)
|
||||
.then(payload => setEntries(payload.items || []))
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '知识点加载失败'));
|
||||
}, [chapterId]);
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Handbook</Text>
|
||||
<Text className='student-title'>知识手册</Text>
|
||||
<Text className='student-subtitle'>内容由后端导入管线规范化,前端只负责阅读和筛选。</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>手册科目</Text>
|
||||
<View className='list-stack'>
|
||||
{subjects.map(item => (
|
||||
<View className='list-row' key={item.id} onClick={() => setSubjectId(item.id)}>
|
||||
<Text className='row-main'>{item.name}</Text>
|
||||
<Text className='row-meta'>{item.description || item.type || '知识手册'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>章节</Text>
|
||||
<View className='toolbar'>
|
||||
{chapters.map(item => (
|
||||
<View className={`pill-button ${item.id === chapterId ? 'active' : ''}`} key={item.id} onClick={() => setChapterId(item.id)}>
|
||||
<Text>{item.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>知识点</Text>
|
||||
<View className='list-stack'>
|
||||
{entries.map(item => (
|
||||
<View className='list-row' key={item.id}>
|
||||
<Text className='row-main'>{item.title}</Text>
|
||||
<Text className='row-meta'>{item.summary || item.content || '暂无内容'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!entries.length ? <View className='empty-state'>当前章节暂无知识点。</View> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,10 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.entry-tile.compact {
|
||||
min-height: 104px;
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
display: block;
|
||||
color: #111827;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { getTenantContext } from '@/services/api';
|
||||
import { loadStudentDashboard, type DashboardSnapshot } from '@/services/catalog';
|
||||
@@ -17,6 +18,14 @@ 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/catalog/index', meta: '顺序 / 随机 / 模考' },
|
||||
{ name: '背单词', path: '/pages/student/vocabulary/index', meta: '复习计划' },
|
||||
{ name: '知识手册', path: '/pages/student/handbook/index', meta: '章节阅读' },
|
||||
{ name: '分数线', path: '/pages/student/scoreline/index', meta: '院校趋势' },
|
||||
{ name: '资料', path: '/pages/student/assets/index', meta: 'PDF 预览' },
|
||||
{ name: '个人中心', path: '/pages/student/profile/index', meta: '会员 / 订单' },
|
||||
];
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
@@ -25,26 +34,38 @@ export default function StudentHomePage() {
|
||||
<Text className='brand'>{brandName}</Text>
|
||||
<Text className='subline'>{userName ? `${userName},继续学习` : '题库、手册、资料和会员统一入口'}</Text>
|
||||
</View>
|
||||
<Button className='ghost-button'>会员</Button>
|
||||
<Button className='ghost-button' onClick={() => Taro.navigateTo({ url: '/pages/student/profile/index' })}>我的</Button>
|
||||
</View>
|
||||
|
||||
<View className='hero-band'>
|
||||
<Text className='hero-title'>今日学习</Text>
|
||||
<Text className='hero-copy'>后端已接入多租户题库、练习快照、错题、收藏、单词和知识手册接口。</Text>
|
||||
<Text className='hero-copy'>题库、背单词、知识手册、分数线、资料和会员都已接入新 SaaS 后端。</Text>
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>学习入口</Text>
|
||||
<View className='entry-grid'>
|
||||
{entryNames.length ? entryNames.map(item => (
|
||||
<View className='entry-tile' key={item.id}>
|
||||
<View className='entry-tile' key={item.id} onClick={() => Taro.navigateTo({ url: `/pages/student/catalog/index?entryId=${item.id}` })}>
|
||||
<Text className='entry-name'>{item.name}</Text>
|
||||
<Text className='entry-type'>{item.entryType || 'content'}</Text>
|
||||
</View>
|
||||
)) : ['刷题', '背单词', '知识手册', '分数线', '资料下载', '会员中心'].map(name => (
|
||||
<View className='entry-tile' key={name}>
|
||||
<Text className='entry-name'>{name}</Text>
|
||||
<Text className='entry-type'>待联调</Text>
|
||||
)) : quickActions.map(item => (
|
||||
<View className='entry-tile' key={item.name} onClick={() => Taro.navigateTo({ url: item.path })}>
|
||||
<Text className='entry-name'>{item.name}</Text>
|
||||
<Text className='entry-type'>{item.meta}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>功能导航</Text>
|
||||
<View className='entry-grid'>
|
||||
{quickActions.map(item => (
|
||||
<View className='entry-tile compact' key={item.path} onClick={() => Taro.navigateTo({ url: item.path })}>
|
||||
<Text className='entry-name'>{item.name}</Text>
|
||||
<Text className='entry-type'>{item.meta}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
3
apps/taro/src/pages/student/login/index.config.ts
Normal file
3
apps/taro/src/pages/student/login/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '登录',
|
||||
});
|
||||
79
apps/taro/src/pages/student/login/index.tsx
Normal file
79
apps/taro/src/pages/student/login/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import { getTenantContext } from '@/services/api';
|
||||
import { loadCurrentUser, sendSmsCode, verifySmsCode } from '@/services/auth';
|
||||
import '../student.css';
|
||||
|
||||
export default function StudentLoginPage() {
|
||||
const tenant = getTenantContext();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [debugCode, setDebugCode] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentUser()
|
||||
.then(() => Taro.redirectTo({ url: '/pages/student/home/index' }))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
async function handleSendCode() {
|
||||
setSending(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await sendSmsCode(phone);
|
||||
const nextCode = typeof result.debugCode === 'string' ? result.debugCode : '';
|
||||
setDebugCode(nextCode);
|
||||
setMessage(nextCode ? `验证码已发送,开发环境验证码 ${nextCode}` : '验证码已发送');
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '验证码发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
setVerifying(true);
|
||||
setError('');
|
||||
try {
|
||||
await verifySmsCode(phone, code || debugCode);
|
||||
Taro.redirectTo({ url: '/pages/student/home/index' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '登录失败');
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>{tenant?.branding.shortName || tenant?.branding.brandName || '题库系统'}</Text>
|
||||
<Text className='student-title'>手机号登录</Text>
|
||||
<Text className='student-subtitle'>短信登录会由后端校验租户、验证码、会话和用户身份。</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='list-stack'>
|
||||
<Input className='input' type='number' maxlength={11} placeholder='请输入手机号' value={phone} onInput={event => setPhone(String(event.detail.value || ''))} />
|
||||
<View className='toolbar'>
|
||||
<Input className='input' type='number' maxlength={6} placeholder='验证码' value={code} onInput={event => setCode(String(event.detail.value || ''))} />
|
||||
<Button className='secondary-button' loading={sending} onClick={handleSendCode}>
|
||||
发送
|
||||
</Button>
|
||||
</View>
|
||||
<Button className='primary-button' loading={verifying} onClick={handleLogin}>
|
||||
登录
|
||||
</Button>
|
||||
{message ? <Text className='success-text'>{message}</Text> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/practice/index.config.ts
Normal file
3
apps/taro/src/pages/student/practice/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '练习',
|
||||
});
|
||||
126
apps/taro/src/pages/student/practice/index.tsx
Normal file
126
apps/taro/src/pages/student/practice/index.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
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 '../student.css';
|
||||
|
||||
function plainOption(option: unknown, index: number) {
|
||||
if (typeof option === 'string') return option;
|
||||
if (option && typeof option === 'object') {
|
||||
const data = option as Record<string, unknown>;
|
||||
return String(data.text || data.content || data.label || data.value || `选项 ${index + 1}`);
|
||||
}
|
||||
return `选项 ${index + 1}`;
|
||||
}
|
||||
|
||||
export default function StudentPracticePage() {
|
||||
const router = useRouter();
|
||||
const params = router.params || {};
|
||||
const [session, setSession] = useState<PracticeSession | null>(null);
|
||||
const [questions, setQuestions] = useState<QuestionItem[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [answerText, setAnswerText] = useState('');
|
||||
const [resultByQuestion, setResultByQuestion] = useState<Record<string, boolean | null>>({});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const collectionId = params.collectionId || undefined;
|
||||
const body = {
|
||||
blueprintId: params.blueprintId || undefined,
|
||||
collectionId,
|
||||
entryId: params.entryId || undefined,
|
||||
contentNodeId: params.contentNodeId || undefined,
|
||||
mode: params.mode || 'sequential',
|
||||
questionLimit: 50,
|
||||
};
|
||||
createPracticeSession(body)
|
||||
.then(async payload => {
|
||||
const nextSession = payload.item;
|
||||
setSession(nextSession);
|
||||
const questionPayload = collectionId
|
||||
? await loadCollectionQuestions(collectionId, 300)
|
||||
: await loadQuestions({ entryId: body.entryId, contentNodeId: body.contentNodeId, limit: 300 });
|
||||
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 || []);
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '练习创建失败'));
|
||||
}, []);
|
||||
|
||||
const current = questions[index];
|
||||
const options = useMemo(() => Array.isArray(current?.options) ? current.options : [], [current]);
|
||||
const isSubmitted = current ? current.id in resultByQuestion : false;
|
||||
|
||||
function toggleOption(optionIndex: number) {
|
||||
const value = String(optionIndex);
|
||||
setSelected(prev => prev.includes(value) ? prev.filter(item => item !== value) : [...prev, value]);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!session || !current) return;
|
||||
try {
|
||||
const payload = await submitAnswer({
|
||||
practiceSessionId: session.id,
|
||||
questionId: current.id,
|
||||
selectedOptions: selected,
|
||||
answerText,
|
||||
});
|
||||
setResultByQuestion(prev => ({ ...prev, [current.id]: payload.item.isCorrect }));
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '提交失败');
|
||||
}
|
||||
}
|
||||
|
||||
function nextQuestion() {
|
||||
setSelected([]);
|
||||
setAnswerText('');
|
||||
setIndex(prev => Math.min(questions.length - 1, prev + 1));
|
||||
}
|
||||
|
||||
async function handleFavorite() {
|
||||
if (!current) return;
|
||||
await toggleQuestionFavorite(current.id, true).catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败'));
|
||||
}
|
||||
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{current ? (
|
||||
<View className='list-stack'>
|
||||
<View className='quiet-panel'>
|
||||
<Text className='row-meta'>{current.typeLabel || current.type || '题目'}</Text>
|
||||
<Text className='row-main'>{current.content || '未提供题干'}</Text>
|
||||
</View>
|
||||
{options.length ? options.map((option, optionIndex) => (
|
||||
<View className='list-row' key={String(optionIndex)} onClick={() => toggleOption(optionIndex)}>
|
||||
<Text className='row-main'>{selected.includes(String(optionIndex)) ? '已选 · ' : ''}{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)}</Text>
|
||||
</View>
|
||||
)) : (
|
||||
<Textarea className='textarea' placeholder='请输入答案' value={answerText} onInput={event => setAnswerText(String(event.detail.value || ''))} />
|
||||
)}
|
||||
{isSubmitted ? (
|
||||
<View className='quiet-panel'>
|
||||
<Text className={resultByQuestion[current.id] ? 'success-text' : 'error-text'}>{resultByQuestion[current.id] ? '回答正确' : '回答错误'}</Text>
|
||||
{current.explanation ? <Text className='row-meta'>{current.explanation}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View className='toolbar'>
|
||||
<Button className='primary-button' onClick={handleSubmit}>提交</Button>
|
||||
<Button className='secondary-button' onClick={handleFavorite}>收藏</Button>
|
||||
<Button className='secondary-button' onClick={nextQuestion}>下一题</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : <View className='empty-state'>暂无题目。请确认后台已发布题目并配置集合或练习蓝图。</View>}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/profile/index.config.ts
Normal file
3
apps/taro/src/pages/student/profile/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '个人中心',
|
||||
});
|
||||
151
apps/taro/src/pages/student/profile/index.tsx
Normal file
151
apps/taro/src/pages/student/profile/index.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import { logout } from '@/services/auth';
|
||||
import { checkActivationCode, loadEntitlements, loadOrders, loadSvipPlans, redeemActivationCode, type OrderItem, type SvipPlan } from '@/services/commerce';
|
||||
import { loadLeaderboard } from '@/services/learning';
|
||||
import { checkIn, loadBadges, loadExamCountdowns, loadProfile, type StudentProfile } from '@/services/profile';
|
||||
import '../student.css';
|
||||
|
||||
export default function StudentProfilePage() {
|
||||
const [profile, setProfile] = useState<StudentProfile | null>(null);
|
||||
const [plans, setPlans] = useState<SvipPlan[]>([]);
|
||||
const [orders, setOrders] = useState<OrderItem[]>([]);
|
||||
const [badges, setBadges] = useState<Record<string, unknown>[]>([]);
|
||||
const [countdowns, setCountdowns] = useState<Record<string, unknown>[]>([]);
|
||||
const [leaderboard, setLeaderboard] = useState<Record<string, unknown>[]>([]);
|
||||
const [activationCode, setActivationCode] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function reload() {
|
||||
Promise.all([
|
||||
loadProfile().catch(() => ({ item: null })),
|
||||
loadSvipPlans().catch(() => ({ items: [] })),
|
||||
loadOrders().catch(() => ({ items: [] })),
|
||||
loadBadges().catch(() => ({ items: [] })),
|
||||
loadExamCountdowns().catch(() => ({ items: [] })),
|
||||
loadLeaderboard('questions').catch(() => ({ items: [] })),
|
||||
loadEntitlements().catch(() => ({ summary: {} })),
|
||||
]).then(([profilePayload, planPayload, orderPayload, badgePayload, countdownPayload, leaderboardPayload]) => {
|
||||
setProfile(profilePayload.item || null);
|
||||
setPlans(planPayload.items || []);
|
||||
setOrders(orderPayload.items || []);
|
||||
setBadges(badgePayload.items || []);
|
||||
setCountdowns(countdownPayload.items || []);
|
||||
setLeaderboard(leaderboardPayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '个人中心加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
async function handleCheckIn() {
|
||||
try {
|
||||
const payload = await checkIn();
|
||||
setMessage(`签到成功,当前积分 ${String(payload.item?.score ?? '')}`);
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '签到失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeem() {
|
||||
try {
|
||||
await checkActivationCode(activationCode);
|
||||
await redeemActivationCode(activationCode);
|
||||
setMessage('激活码兑换成功');
|
||||
setActivationCode('');
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '激活码兑换失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
Taro.redirectTo({ url: '/pages/student/login/index' });
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Profile</Text>
|
||||
<Text className='student-title'>{profile?.name || profile?.phone || '个人中心'}</Text>
|
||||
<Text className='student-subtitle'>{profile?.membership?.isSvip ? 'SVIP 生效中' : '普通用户'} · 积分 {profile?.score ?? 0}</Text>
|
||||
</View>
|
||||
<Button className='secondary-button' onClick={handleLogout}>退出</Button>
|
||||
</View>
|
||||
|
||||
<View className='grid-two'>
|
||||
<View className='metric'><Text className='metric-value'>{String((profile?.stats?.answers as Record<string, unknown> | undefined)?.totalAnswered ?? 0)}</Text><Text className='metric-label'>累计答题</Text></View>
|
||||
<View className='metric'><Text className='metric-value'>{String((profile?.stats?.vocabulary as Record<string, unknown> | undefined)?.masteredWords ?? 0)}</Text><Text className='metric-label'>掌握单词</Text></View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>目标与考试</Text>
|
||||
<View className='list-stack'>
|
||||
<View className='list-row'>
|
||||
<Text className='row-main'>{profile?.target?.regionName || '未选择地区'}{profile?.target?.schoolName ? ` · ${profile.target.schoolName}` : ''}</Text>
|
||||
<Text className='row-meta'>{profile?.target?.majorName || '暂无目标专业'}</Text>
|
||||
</View>
|
||||
{countdowns.slice(0, 3).map(item => (
|
||||
<View className='list-row' key={String(item.id || item.examName)}>
|
||||
<Text className='row-main'>{String(item.examName || '考试')}</Text>
|
||||
<Text className='row-meta'>剩余 {String(item.daysLeft ?? '-')} 天</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>会员套餐</Text>
|
||||
<View className='list-stack'>
|
||||
{plans.slice(0, 4).map(item => (
|
||||
<View className='list-row' key={item.id}>
|
||||
<Text className='row-main'>{item.name} · ¥{String(item.price ?? 0)}</Text>
|
||||
<Text className='row-meta'>{item.days === -1 ? '永久' : `${item.days || 0} 天`} {item.badge ? ` · ${item.badge}` : ''}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>激活码</Text>
|
||||
<View className='toolbar'>
|
||||
<Input className='input' placeholder='输入激活码' value={activationCode} onInput={event => setActivationCode(String(event.detail.value || ''))} />
|
||||
<Button className='primary-button' onClick={handleRedeem}>兑换</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>订单</Text>
|
||||
<View className='list-stack'>
|
||||
{orders.slice(0, 5).map(item => (
|
||||
<View className='list-row' key={item.id}>
|
||||
<Text className='row-main'>{item.productName || item.orderNo}</Text>
|
||||
<Text className='row-meta'>{item.status} · ¥{((item.amountCents || 0) / 100).toFixed(2)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>运营数据</Text>
|
||||
<View className='toolbar'>
|
||||
<Button className='primary-button' onClick={handleCheckIn}>签到</Button>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/catalog/index' })}>刷题</Button>
|
||||
</View>
|
||||
<View className='grid-two'>
|
||||
<View className='metric'><Text className='metric-value'>{String(badges.length)}</Text><Text className='metric-label'>勋章</Text></View>
|
||||
<View className='metric'><Text className='metric-value'>{String(leaderboard.length)}</Text><Text className='metric-label'>排行榜样本</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{message ? <Text className='success-text'>{message}</Text> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
3
apps/taro/src/pages/student/scoreline/index.config.ts
Normal file
3
apps/taro/src/pages/student/scoreline/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '分数线',
|
||||
});
|
||||
42
apps/taro/src/pages/student/scoreline/index.tsx
Normal file
42
apps/taro/src/pages/student/scoreline/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, View } from '@tarojs/components';
|
||||
import { loadScorelineRecords, type ScorelineRecord } from '@/services/catalog';
|
||||
import '../student.css';
|
||||
|
||||
function fieldText(values: Record<string, unknown> | undefined) {
|
||||
if (!values) return '暂无字段';
|
||||
return Object.entries(values).slice(0, 4).map(([key, value]) => `${key}: ${String(value)}`).join(' · ');
|
||||
}
|
||||
|
||||
export default function StudentScorelinePage() {
|
||||
const [records, setRecords] = useState<ScorelineRecord[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadScorelineRecords({ pageSize: 50 })
|
||||
.then(payload => setRecords(payload.items || []))
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '分数线加载失败'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Scoreline</Text>
|
||||
<Text className='student-title'>历年分数线</Text>
|
||||
<Text className='student-subtitle'>动态字段由租户后台配置,适配不同地区和院校规则。</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='list-stack'>
|
||||
{records.map(item => (
|
||||
<View className='list-row' key={item.id}>
|
||||
<Text className='row-main'>{item.year} · {item.schoolName || '院校'}{item.majorName ? ` · ${item.majorName}` : ''}</Text>
|
||||
<Text className='row-meta'>{fieldText(item.fieldValues)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!records.length ? <View className='empty-state'>暂无分数线数据。</View> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
213
apps/taro/src/pages/student/student.css
Normal file
213
apps/taro/src/pages/student/student.css
Normal file
@@ -0,0 +1,213 @@
|
||||
.student-page {
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.student-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.student-title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.student-kicker {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.student-title {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #111827;
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.student-subtitle {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 24px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.pill-button,
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.danger-button {
|
||||
min-width: 120px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 64px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: 0;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.pill-button {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.pill-button.active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
color: #172033;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.quiet-panel {
|
||||
padding: 26px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.list-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
padding: 22px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
display: block;
|
||||
color: #111827;
|
||||
font-size: 28px;
|
||||
font-weight: 750;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 22px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 22px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 38px 26px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
padding: 0 20px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-size: 26px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
color: #be123c;
|
||||
font-size: 22px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
color: #047857;
|
||||
font-size: 22px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: #64748b;
|
||||
}
|
||||
3
apps/taro/src/pages/student/vocabulary/index.config.ts
Normal file
3
apps/taro/src/pages/student/vocabulary/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '背单词',
|
||||
});
|
||||
95
apps/taro/src/pages/student/vocabulary/index.tsx
Normal file
95
apps/taro/src/pages/student/vocabulary/index.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { loadVocabularyUnits, loadVocabularyWords, type VocabularyUnit } from '@/services/catalog';
|
||||
import { loadVocabularyReviewPlan, loadVocabularyStats, reviewVocabularyWord, toggleWordFavorite, type VocabularyWord } from '@/services/learning';
|
||||
import '../student.css';
|
||||
|
||||
export default function StudentVocabularyPage() {
|
||||
const [units, setUnits] = useState<VocabularyUnit[]>([]);
|
||||
const [unitId, setUnitId] = useState('');
|
||||
const [words, setWords] = useState<VocabularyWord[]>([]);
|
||||
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadVocabularyUnits()
|
||||
.then(payload => {
|
||||
const nextUnits = payload.items || [];
|
||||
setUnits(nextUnits);
|
||||
if (nextUnits[0]) setUnitId(nextUnits[0].id);
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '单词单元加载失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unitId) return;
|
||||
Promise.all([loadVocabularyReviewPlan(unitId), loadVocabularyWords(unitId), loadVocabularyStats(unitId)])
|
||||
.then(([plan, wordPayload, statPayload]) => {
|
||||
const planned = plan.item?.words || [];
|
||||
setWords(planned.length ? planned : wordPayload.items || []);
|
||||
setStats(statPayload.item || null);
|
||||
})
|
||||
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '单词加载失败'));
|
||||
}, [unitId]);
|
||||
|
||||
async function mark(word: VocabularyWord, result: 'known' | 'unknown') {
|
||||
const wordId = word.wordId || word.id;
|
||||
if (!wordId) return;
|
||||
await reviewVocabularyWord(wordId, result).catch(nextError => setError(nextError instanceof Error ? nextError.message : '复习提交失败'));
|
||||
setWords(prev => prev.filter(item => (item.wordId || item.id) !== wordId));
|
||||
}
|
||||
|
||||
async function favorite(word: VocabularyWord) {
|
||||
const wordId = word.wordId || word.id;
|
||||
if (!wordId) return;
|
||||
await toggleWordFavorite(wordId, true).catch(nextError => setError(nextError instanceof Error ? nextError.message : '收藏失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='student-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Vocabulary</Text>
|
||||
<Text className='student-title'>背单词</Text>
|
||||
<Text className='student-subtitle'>每日计划、复习调度和掌握状态都由后端维护。</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='toolbar'>
|
||||
{units.map(item => (
|
||||
<Button key={item.id} className={`pill-button ${item.id === unitId ? 'active' : ''}`} onClick={() => setUnitId(item.id)}>
|
||||
{item.name}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>学习统计</Text>
|
||||
<View className='grid-two'>
|
||||
<View className='metric'><Text className='metric-value'>{String(stats?.masteredWords ?? 0)}</Text><Text className='metric-label'>已掌握</Text></View>
|
||||
<View className='metric'><Text className='metric-value'>{String(stats?.todayReviewed ?? 0)}</Text><Text className='metric-label'>今日复习</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>今日单词</Text>
|
||||
<View className='list-stack'>
|
||||
{words.slice(0, 20).map(word => (
|
||||
<View className='list-row' key={word.wordId || word.id || word.word}>
|
||||
<Text className='row-main'>{word.word} {word.phonetic ? `/${word.phonetic}/` : ''}</Text>
|
||||
<Text className='row-meta'>{word.meaning || '暂无释义'}</Text>
|
||||
{word.example ? <Text className='row-meta'>{word.example}</Text> : null}
|
||||
<View className='toolbar'>
|
||||
<Button className='primary-button' onClick={() => mark(word, 'known')}>认识</Button>
|
||||
<Button className='secondary-button' onClick={() => mark(word, 'unknown')}>再记</Button>
|
||||
<Button className='secondary-button' onClick={() => favorite(word)}>收藏</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!words.length ? <View className='empty-state'>当前单元暂无待学习单词。</View> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface ContentEntry {
|
||||
route?: string;
|
||||
icon?: string;
|
||||
sortOrder?: number;
|
||||
description?: string;
|
||||
regionId?: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardSnapshot {
|
||||
@@ -30,3 +32,169 @@ export async function loadStudentDashboard(regionId?: string): Promise<Dashboard
|
||||
profile: profile.item || null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RegionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
shortName?: string;
|
||||
isHot?: boolean;
|
||||
}
|
||||
|
||||
export interface ContentNode {
|
||||
id: string;
|
||||
entryId: string;
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
nodeType?: string;
|
||||
markerType?: string | null;
|
||||
depth?: number;
|
||||
isLeaf?: boolean;
|
||||
isSelectable?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QuestionCollection {
|
||||
id: string;
|
||||
entryId?: string | null;
|
||||
nodeId?: string | null;
|
||||
name: string;
|
||||
collectionType?: string;
|
||||
questionCount?: number;
|
||||
durationMinutes?: number | null;
|
||||
}
|
||||
|
||||
export interface PracticeBlueprint {
|
||||
id: string;
|
||||
entryId?: string | null;
|
||||
nodeId?: string | null;
|
||||
collectionId?: string | null;
|
||||
name: string;
|
||||
mode: string;
|
||||
questionLimit?: number | null;
|
||||
durationMinutes?: number | null;
|
||||
}
|
||||
|
||||
export interface VocabularyUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
wordCount?: number;
|
||||
regionId?: string | null;
|
||||
}
|
||||
|
||||
export interface HandbookSubject {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface HandbookChapter {
|
||||
id: string;
|
||||
subjectId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface HandbookEntry {
|
||||
id: string;
|
||||
chapterId: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
content?: string | null;
|
||||
}
|
||||
|
||||
export interface ScorelineRecord {
|
||||
id: string;
|
||||
year: number;
|
||||
schoolName?: string | null;
|
||||
majorName?: string | null;
|
||||
fieldValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ContentAsset {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
assetType?: string;
|
||||
categoryLabel?: string | null;
|
||||
description?: string | null;
|
||||
fileName?: string | null;
|
||||
mimeType?: string | null;
|
||||
visibility?: string;
|
||||
previewStatus?: string;
|
||||
}
|
||||
|
||||
export async function loadRegions() {
|
||||
return apiRequest<{ items?: RegionItem[] }>('/api/catalog/regions');
|
||||
}
|
||||
|
||||
export async function loadContentEntries(regionId?: string, entryType?: string) {
|
||||
return apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId, entryType } });
|
||||
}
|
||||
|
||||
export async function loadContentNodes(entryId: string, parentId: string | null = 'root', mode: 'children' | 'flat' = 'children') {
|
||||
return apiRequest<{ items?: ContentNode[] }>('/api/catalog/content-nodes', { query: { entryId, parentId, mode } });
|
||||
}
|
||||
|
||||
export async function loadQuestionCollections(input: { entryId?: string; nodeId?: string; limit?: number }) {
|
||||
return apiRequest<{ items?: QuestionCollection[] }>('/api/catalog/question-collections', {
|
||||
query: { entryId: input.entryId, nodeId: input.nodeId, limit: input.limit || 100 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadCollectionQuestions(collectionId: string, limit = 200) {
|
||||
return apiRequest<{ items?: import('./learning').QuestionItem[] }>('/api/catalog/question-collections/questions', {
|
||||
query: { collectionId, limit },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadQuestions(query: { entryId?: string; contentNodeId?: string; collectionId?: string; limit?: number }) {
|
||||
return apiRequest<{ items?: import('./learning').QuestionItem[] }>('/api/catalog/questions', {
|
||||
query: { ...query, limit: query.limit || 200 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPracticeBlueprints(input: { entryId?: string; nodeId?: string; collectionId?: string; mode?: string }) {
|
||||
return apiRequest<{ items?: PracticeBlueprint[] }>('/api/catalog/practice-blueprints', {
|
||||
query: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadVocabularyUnits(regionId?: string) {
|
||||
return apiRequest<{ items?: VocabularyUnit[] }>('/api/catalog/vocabulary-units', { query: { regionId } });
|
||||
}
|
||||
|
||||
export async function loadVocabularyWords(unitId: string) {
|
||||
return apiRequest<{ items?: import('./learning').VocabularyWord[] }>('/api/catalog/vocabulary-words', { query: { unitId } });
|
||||
}
|
||||
|
||||
export async function loadHandbookSubjects(regionId?: string) {
|
||||
return apiRequest<{ items?: HandbookSubject[] }>('/api/catalog/handbook-subjects', { query: { regionId } });
|
||||
}
|
||||
|
||||
export async function loadHandbookChapters(subjectId: string) {
|
||||
return apiRequest<{ items?: HandbookChapter[] }>('/api/catalog/handbook-chapters', { query: { subjectId } });
|
||||
}
|
||||
|
||||
export async function loadHandbookEntries(chapterId: string, includeContent = true) {
|
||||
return apiRequest<{ items?: HandbookEntry[] }>('/api/catalog/handbook-entries', { query: { chapterId, includeContent } });
|
||||
}
|
||||
|
||||
export async function loadScorelineRecords(query: { regionId?: string; schoolId?: string; majorId?: string; year?: number; pageSize?: number } = {}) {
|
||||
return apiRequest<{ items?: ScorelineRecord[]; total?: number }>('/api/scoreline/records', {
|
||||
query: { ...query, pageSize: query.pageSize || 20 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadContentAssets(query: { entryId?: string; contentNodeId?: string; assetType?: string; includeLocked?: boolean } = {}) {
|
||||
return apiRequest<{ items?: ContentAsset[] }>('/api/catalog/assets', { query: { ...query, includeLocked: query.includeLocked ?? true } });
|
||||
}
|
||||
|
||||
export async function signAssetPreview(assetId: string) {
|
||||
return apiRequest<{ preview?: { url?: string }; item?: ContentAsset }>('/api/catalog/assets/preview', { query: { assetId } });
|
||||
}
|
||||
|
||||
export async function signAssetDownload(assetId: string) {
|
||||
return apiRequest<{ download?: { url?: string }; item?: ContentAsset }>('/api/catalog/assets/download', { query: { assetId } });
|
||||
}
|
||||
|
||||
63
apps/taro/src/services/commerce.ts
Normal file
63
apps/taro/src/services/commerce.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export interface SvipPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
price?: number;
|
||||
priceCents?: number;
|
||||
originalPrice?: number | null;
|
||||
days?: number;
|
||||
desc?: string | null;
|
||||
badge?: string | null;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
productName?: string | null;
|
||||
amountCents?: number;
|
||||
paidAt?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export async function loadSvipPlans(regionId?: string) {
|
||||
return apiRequest<{ items?: SvipPlan[] }>('/api/catalog/svip-plans', { query: { regionId } });
|
||||
}
|
||||
|
||||
export async function createOrder(body: {
|
||||
planId: string;
|
||||
regionId?: string;
|
||||
payMethod?: string;
|
||||
payProvider?: string;
|
||||
quantity?: number;
|
||||
couponCode?: string;
|
||||
}) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/commerce/orders', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadOrders(limit = 20) {
|
||||
return apiRequest<{ items?: OrderItem[] }>('/api/commerce/orders', { query: { limit } });
|
||||
}
|
||||
|
||||
export async function loadEntitlements() {
|
||||
return apiRequest<{ items?: Record<string, unknown>[]; summary?: Record<string, unknown> }>('/api/commerce/entitlements');
|
||||
}
|
||||
|
||||
export async function checkActivationCode(code: string, regionId?: string) {
|
||||
return apiRequest<{ valid?: boolean; item?: Record<string, unknown>; reasonCode?: string; message?: string }>(
|
||||
'/api/commerce/activation-codes/check',
|
||||
{ method: 'POST', body: { code, regionId } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function redeemActivationCode(code: string, regionId?: string) {
|
||||
return apiRequest<{ item?: Record<string, unknown>; entitlement?: unknown }>('/api/commerce/activation-codes/redeem', {
|
||||
method: 'POST',
|
||||
body: { code, regionId },
|
||||
});
|
||||
}
|
||||
129
apps/taro/src/services/learning.ts
Normal file
129
apps/taro/src/services/learning.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export interface QuestionOption {
|
||||
text?: string;
|
||||
label?: string;
|
||||
value?: string | number;
|
||||
content?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface QuestionItem {
|
||||
id: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
typeLabel?: string;
|
||||
options?: QuestionOption[] | string[] | null;
|
||||
answerText?: string | null;
|
||||
correctOptionIndex?: number | null;
|
||||
correctOptionIndices?: number[] | null;
|
||||
explanation?: string | null;
|
||||
hasVideoExplanation?: boolean;
|
||||
}
|
||||
|
||||
export interface PracticeSession {
|
||||
id: string;
|
||||
mode: string;
|
||||
questionIds: string[];
|
||||
questionCount: number;
|
||||
durationMinutes?: number | null;
|
||||
accessMode?: string;
|
||||
consumedFreeQuota?: number;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AnswerResult {
|
||||
id: string;
|
||||
questionId: string;
|
||||
isCorrect: boolean | null;
|
||||
}
|
||||
|
||||
export interface VocabularyWord {
|
||||
id?: string;
|
||||
wordId?: string;
|
||||
unitId?: string;
|
||||
word: string;
|
||||
phonetic?: string | null;
|
||||
meaning?: string | null;
|
||||
example?: string | null;
|
||||
exampleTranslation?: string | null;
|
||||
difficulty?: string | null;
|
||||
status?: string;
|
||||
dueLevel?: string;
|
||||
}
|
||||
|
||||
export async function createPracticeSession(body: {
|
||||
blueprintId?: string;
|
||||
collectionId?: string;
|
||||
entryId?: string;
|
||||
contentNodeId?: string;
|
||||
mode?: string;
|
||||
questionLimit?: number;
|
||||
}) {
|
||||
return apiRequest<{ item: PracticeSession }>('/api/learning/practice-sessions', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitAnswer(body: {
|
||||
practiceSessionId: string;
|
||||
questionId: string;
|
||||
selectedOptions?: string[];
|
||||
answerText?: string;
|
||||
}) {
|
||||
return apiRequest<{ item: AnswerResult }>('/api/learning/answers', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleQuestionFavorite(questionId: string, favorite: boolean) {
|
||||
return apiRequest<{ ok: boolean; favorite: boolean }>('/api/learning/favorites/questions', {
|
||||
method: 'POST',
|
||||
body: { questionId, favorite },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadWrongQuestions(limit = 30) {
|
||||
return apiRequest<{ items?: QuestionItem[] }>('/api/learning/wrong-questions', { query: { limit } });
|
||||
}
|
||||
|
||||
export async function loadFavoriteQuestions(limit = 30) {
|
||||
return apiRequest<{ items?: QuestionItem[] }>('/api/learning/favorites/questions', { query: { limit } });
|
||||
}
|
||||
|
||||
export async function loadLearningStats() {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/learning/stats');
|
||||
}
|
||||
|
||||
export async function loadLeaderboard(metric: 'questions' | 'score' | 'vocabulary' | 'mock_exam' = 'questions') {
|
||||
return apiRequest<{ items?: Record<string, unknown>[]; currentUserRank?: unknown }>('/api/learning/leaderboard', {
|
||||
query: { metric, period: '7d', scope: 'tenant' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadVocabularyReviewPlan(unitId?: string) {
|
||||
return apiRequest<{ item?: { words?: VocabularyWord[]; dueCount?: number; newCount?: number; totalPlanned?: number } }>(
|
||||
'/api/learning/vocabulary/review-plan',
|
||||
{ query: { unitId, reviewLimit: 20, newLimit: 10 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function reviewVocabularyWord(wordId: string, result: 'known' | 'unknown') {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/learning/vocabulary/review', {
|
||||
method: 'POST',
|
||||
body: { wordId, result },
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleWordFavorite(wordId: string, favorite: boolean) {
|
||||
return apiRequest<{ ok: boolean; favorite: boolean }>('/api/learning/vocabulary/favorites', {
|
||||
method: 'POST',
|
||||
body: { wordId, favorite },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadVocabularyStats(unitId?: string) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/learning/vocabulary/stats', { query: { unitId } });
|
||||
}
|
||||
35
apps/taro/src/services/profile.ts
Normal file
35
apps/taro/src/services/profile.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export interface StudentProfile {
|
||||
userId?: string;
|
||||
name?: string | null;
|
||||
phone?: string | null;
|
||||
score?: number;
|
||||
target?: {
|
||||
regionName?: string | null;
|
||||
schoolName?: string | null;
|
||||
majorName?: string | null;
|
||||
};
|
||||
membership?: {
|
||||
isSvip?: boolean;
|
||||
entitlement?: unknown;
|
||||
};
|
||||
stats?: Record<string, unknown>;
|
||||
recentPractices?: unknown[];
|
||||
}
|
||||
|
||||
export async function loadProfile() {
|
||||
return apiRequest<{ item?: StudentProfile }>('/api/profile/me');
|
||||
}
|
||||
|
||||
export async function checkIn() {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/profile/check-in', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function loadBadges() {
|
||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/profile/badges');
|
||||
}
|
||||
|
||||
export async function loadExamCountdowns() {
|
||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/profile/exam-countdowns');
|
||||
}
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
## 当前可进入的前端工作
|
||||
|
||||
- 可以开始搭建 `apps/taro`。
|
||||
- 可以复刻旧题库学生端主要视觉和交互:登录、选地区、首页、刷题、背单词、知识手册、分数线、资料、商城、个人中心、勋章。
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、题库、练习、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:选地区、刷题细节、视频解析、题目反馈、模考报告、错题复习、商城收银台、订单详情、勋章展示。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
- `content_entries`
|
||||
- `content_nodes`
|
||||
@@ -54,3 +54,20 @@
|
||||
- 每个页面先接后端已有接口;缺接口时把页面期望的字段写到 issue/TODO,再由后端补聚合接口。
|
||||
- 权限判断以后端结果为准,前端只做菜单和按钮可见性优化。
|
||||
- 旧项目只作为样式、交互和字段含义参考;长期数据模型以新 API 为准。
|
||||
|
||||
## 已落地的 Taro 学生端页面
|
||||
|
||||
| 页面 | 文件 | 已接接口 |
|
||||
| --- | --- | --- |
|
||||
| 启动页 | `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/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/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/小程序限制优化。
|
||||
|
||||
@@ -1351,6 +1351,47 @@ ACTIVATION_CODE_REGION_MISMATCH
|
||||
10. `pages/profile/index`
|
||||
- 会员、订单、激活码、学习数据、勋章。
|
||||
|
||||
## 当前 Taro 实现进度
|
||||
|
||||
截至 2026-06-29,`apps/taro` 已完成学生端第一阶段页面:
|
||||
|
||||
```text
|
||||
pages/student/login/index
|
||||
pages/student/home/index
|
||||
pages/student/catalog/index
|
||||
pages/student/practice/index
|
||||
pages/student/vocabulary/index
|
||||
pages/student/handbook/index
|
||||
pages/student/scoreline/index
|
||||
pages/student/assets/index
|
||||
pages/student/profile/index
|
||||
```
|
||||
|
||||
已新增服务层:
|
||||
|
||||
```text
|
||||
src/services/catalog.ts 目录、题库、单词、手册、分数线、资料
|
||||
src/services/learning.ts 练习 session、答题、收藏、单词复习
|
||||
src/services/commerce.ts 套餐、订单、权益、激活码
|
||||
src/services/profile.ts 个人中心、签到、勋章、倒计时
|
||||
```
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
npm run check:taro
|
||||
npm run build:taro:h5:student
|
||||
```
|
||||
|
||||
已通过。构建仍有 Taro H5 入口体积 warning,属于当前 Taro 工程既有警告,不阻断联调。
|
||||
|
||||
下一批前端开发重点:
|
||||
|
||||
- 学生端:选地区、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、订单收银台和订单详情。
|
||||
- 租户后台:数据看板、学生/班级/教师、角色模板、品牌域名、题库导入、公共题库采纳、营销、CRM、分佣。
|
||||
- 平台后台:租户、SaaS 套餐、订阅账单、公共题库授权、平台审计。
|
||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||
|
||||
## 租户后台前端建议
|
||||
|
||||
租户后台可以先做 H5 管理台,也可以后续使用 Taro H5 复用部分组件。优先页面:
|
||||
|
||||
Reference in New Issue
Block a user