diff --git a/README.md b/README.md index d3cc3e6d..92a00dc9 100644 --- a/README.md +++ b/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/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/taro/README.md b/apps/taro/README.md index 036e55f1..41942e52 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -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` 指定租户。 diff --git a/apps/taro/src/app.config.ts b/apps/taro/src/app.config.ts index c1cb0de1..1d91e40f 100644 --- a/apps/taro/src/app.config.ts +++ b/apps/taro/src/app.config.ts @@ -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', ], diff --git a/apps/taro/src/pages/student/assets/index.config.ts b/apps/taro/src/pages/student/assets/index.config.ts new file mode 100644 index 00000000..ffd1ca91 --- /dev/null +++ b/apps/taro/src/pages/student/assets/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '资料', +}); diff --git a/apps/taro/src/pages/student/assets/index.tsx b/apps/taro/src/pages/student/assets/index.tsx new file mode 100644 index 00000000..b63b0946 --- /dev/null +++ b/apps/taro/src/pages/student/assets/index.tsx @@ -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([]); + 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 ( + + + + Assets + 资料下载 + 私有文件必须向后端申请短期签名,前端不直接拼对象存储地址。 + + + + {assets.map(item => ( + + {item.title || item.fileName || '未命名资料'} + {item.assetType || 'asset'} · {item.visibility || 'public'} + {item.description ? {item.description} : null} + + + + + + ))} + + {!assets.length ? 暂无可见资料。 : null} + {error ? {error} : null} + + ); +} diff --git a/apps/taro/src/pages/student/catalog/index.config.ts b/apps/taro/src/pages/student/catalog/index.config.ts new file mode 100644 index 00000000..c2c7fd7d --- /dev/null +++ b/apps/taro/src/pages/student/catalog/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '题库', +}); diff --git a/apps/taro/src/pages/student/catalog/index.tsx b/apps/taro/src/pages/student/catalog/index.tsx new file mode 100644 index 00000000..3cb003ad --- /dev/null +++ b/apps/taro/src/pages/student/catalog/index.tsx @@ -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([]); + const [selectedEntryId, setSelectedEntryId] = useState(query.entryId || ''); + const [nodes, setNodes] = useState([]); + const [selectedNodeId, setSelectedNodeId] = useState(query.nodeId || ''); + const [collections, setCollections] = useState([]); + const [blueprints, setBlueprints] = useState([]); + 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 ( + + + + Question Bank + 题库练习 + {selectedEntry?.description || '按入口、分类、集合和蓝图进入练习,分类层级不在前端写死。'} + + + + + + 入口 + + {entries.map(item => ( + + ))} + + + + + 分类 + {nodes.length ? ( + + {nodes.map(item => ( + setSelectedNodeId(item.id)}> + {item.name} + {item.markerType || item.nodeType || '分类'}{item.isLeaf ? ' · 可练习' : ''} + + ))} + + ) : 暂无分类,导入题库后会在这里展示。} + + + + 练习方式 + + {blueprints.map(item => ( + startPractice({ blueprint: item })}> + {item.name} + {item.mode} · {item.questionLimit || '不限'} 题 + + ))} + {collections.map(item => ( + startPractice({ collection: item, mode: item.collectionType === 'paper' ? 'mock_exam' : 'sequential' })}> + {item.name} + {item.collectionType || 'collection'} · {item.questionCount || 0} 题 + + ))} + + {!collections.length && !blueprints.length ? 当前入口还没有可用集合或练习蓝图。 : null} + {error ? {error} : null} + + + ); +} diff --git a/apps/taro/src/pages/student/handbook/index.config.ts b/apps/taro/src/pages/student/handbook/index.config.ts new file mode 100644 index 00000000..160df72a --- /dev/null +++ b/apps/taro/src/pages/student/handbook/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '知识手册', +}); diff --git a/apps/taro/src/pages/student/handbook/index.tsx b/apps/taro/src/pages/student/handbook/index.tsx new file mode 100644 index 00000000..8f9489d6 --- /dev/null +++ b/apps/taro/src/pages/student/handbook/index.tsx @@ -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([]); + const [chapters, setChapters] = useState([]); + const [entries, setEntries] = useState([]); + 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 ( + + + + Handbook + 知识手册 + 内容由后端导入管线规范化,前端只负责阅读和筛选。 + + + + + 手册科目 + + {subjects.map(item => ( + setSubjectId(item.id)}> + {item.name} + {item.description || item.type || '知识手册'} + + ))} + + + + + 章节 + + {chapters.map(item => ( + setChapterId(item.id)}> + {item.name} + + ))} + + + + + 知识点 + + {entries.map(item => ( + + {item.title} + {item.summary || item.content || '暂无内容'} + + ))} + + {!entries.length ? 当前章节暂无知识点。 : null} + {error ? {error} : null} + + + ); +} diff --git a/apps/taro/src/pages/student/home/index.css b/apps/taro/src/pages/student/home/index.css index 242cddf1..0c06481a 100644 --- a/apps/taro/src/pages/student/home/index.css +++ b/apps/taro/src/pages/student/home/index.css @@ -83,6 +83,10 @@ background: #fff; } +.entry-tile.compact { + min-height: 104px; +} + .entry-name { display: block; color: #111827; diff --git a/apps/taro/src/pages/student/home/index.tsx b/apps/taro/src/pages/student/home/index.tsx index 084c6724..56b57f73 100644 --- a/apps/taro/src/pages/student/home/index.tsx +++ b/apps/taro/src/pages/student/home/index.tsx @@ -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 ( @@ -25,26 +34,38 @@ export default function StudentHomePage() { {brandName} {userName ? `${userName},继续学习` : '题库、手册、资料和会员统一入口'} - + 今日学习 - 后端已接入多租户题库、练习快照、错题、收藏、单词和知识手册接口。 + 题库、背单词、知识手册、分数线、资料和会员都已接入新 SaaS 后端。 学习入口 {entryNames.length ? entryNames.map(item => ( - + Taro.navigateTo({ url: `/pages/student/catalog/index?entryId=${item.id}` })}> {item.name} {item.entryType || 'content'} - )) : ['刷题', '背单词', '知识手册', '分数线', '资料下载', '会员中心'].map(name => ( - - {name} - 待联调 + )) : quickActions.map(item => ( + Taro.navigateTo({ url: item.path })}> + {item.name} + {item.meta} + + ))} + + + + + 功能导航 + + {quickActions.map(item => ( + Taro.navigateTo({ url: item.path })}> + {item.name} + {item.meta} ))} diff --git a/apps/taro/src/pages/student/login/index.config.ts b/apps/taro/src/pages/student/login/index.config.ts new file mode 100644 index 00000000..d8d09bd2 --- /dev/null +++ b/apps/taro/src/pages/student/login/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '登录', +}); diff --git a/apps/taro/src/pages/student/login/index.tsx b/apps/taro/src/pages/student/login/index.tsx new file mode 100644 index 00000000..5459d4c3 --- /dev/null +++ b/apps/taro/src/pages/student/login/index.tsx @@ -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 ( + + + + {tenant?.branding.shortName || tenant?.branding.brandName || '题库系统'} + 手机号登录 + 短信登录会由后端校验租户、验证码、会话和用户身份。 + + + + + setPhone(String(event.detail.value || ''))} /> + + setCode(String(event.detail.value || ''))} /> + + + + {message ? {message} : null} + {error ? {error} : null} + + + ); +} diff --git a/apps/taro/src/pages/student/practice/index.config.ts b/apps/taro/src/pages/student/practice/index.config.ts new file mode 100644 index 00000000..e2e869c4 --- /dev/null +++ b/apps/taro/src/pages/student/practice/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '练习', +}); diff --git a/apps/taro/src/pages/student/practice/index.tsx b/apps/taro/src/pages/student/practice/index.tsx new file mode 100644 index 00000000..46988113 --- /dev/null +++ b/apps/taro/src/pages/student/practice/index.tsx @@ -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; + 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(null); + const [questions, setQuestions] = useState([]); + const [index, setIndex] = useState(0); + const [selected, setSelected] = useState([]); + const [answerText, setAnswerText] = useState(''); + const [resultByQuestion, setResultByQuestion] = useState>({}); + 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 ( + + + + {session ? `${index + 1}/${questions.length || session.questionCount}` : 'Practice'} + {session?.mode || '练习'} + 题目来自后端 session 快照,答题提交会校验当前用户是否拥有该题访问权限。 + + + + {current ? ( + + + {current.typeLabel || current.type || '题目'} + {current.content || '未提供题干'} + + {options.length ? options.map((option, optionIndex) => ( + toggleOption(optionIndex)}> + {selected.includes(String(optionIndex)) ? '已选 · ' : ''}{String.fromCharCode(65 + optionIndex)}. {plainOption(option, optionIndex)} + + )) : ( +