flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:new_learn/core/storage/stores/answer_record_store.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【answerProvider】答题状态 —— Riverpod 内存主状态
|
||||
///
|
||||
/// 一、架构定位(对应方案分析结论):
|
||||
/// 答案主状态必须住内存(每题作答高频读写、交卷即弃),
|
||||
/// Isar 只是"备份恢复层":每次作答同步写一条 AnswerRecord,
|
||||
/// 进程杀掉后下次进卷按 paperId 读回 restore() 续答。
|
||||
///
|
||||
/// 二、数据结构:`Map<questionId, String>`
|
||||
/// key 用服务器题号(Question.questionId),不依赖本地自增 id,
|
||||
/// 这样续答恢复时即使题目重新拉取也能对上。
|
||||
///
|
||||
/// 三、持久化为什么 fire-and-forget:
|
||||
/// put 走 Isar 毫秒级完成,没必要阻塞 UI 作答回调;
|
||||
/// web 端 store 是空实现(打印跳过),行为自动降级为纯内存。
|
||||
/// ============================================================
|
||||
|
||||
/// Demo 试卷 id(真实项目由试卷对象传入,如 paper.paperId)
|
||||
const int demoPaperId = 1001;
|
||||
|
||||
final answerProvider =
|
||||
NotifierProvider<AnswerNotifier, Map<int, String>>(AnswerNotifier.new);
|
||||
|
||||
class AnswerNotifier extends Notifier<Map<int, String>> {
|
||||
@override
|
||||
Map<int, String> build() => {};
|
||||
|
||||
/// 【作答】更新内存 + 同步落盘(续答持久化)
|
||||
void answer(int questionId, String value) {
|
||||
state = {...state, questionId: value};
|
||||
// fire-and-forget:写库毫秒级,不阻塞作答回调
|
||||
unawaited(
|
||||
AnswerRecordStore.instance.put(
|
||||
paperId: demoPaperId,
|
||||
questionId: questionId,
|
||||
answer: value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 【续答恢复】进卷时从 Isar 读回,整体注入(只恢复本卷存在的题)
|
||||
void restore(Map<int, String> saved) {
|
||||
if (saved.isEmpty) return;
|
||||
state = {...state, ...saved};
|
||||
}
|
||||
|
||||
/// 【交卷清理】清空内存状态(Isar 记录由页面调 deleteByPaper)
|
||||
void clear() => state = {};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AnswerSheetDialog】答题卡 —— 题号网格 + 跳题
|
||||
///
|
||||
/// 一、职责:
|
||||
/// 1. 展示所有题号(GridView 4 列),颜色区分答题状态:
|
||||
/// 已答=蓝 / 未答=灰 / 当前题=绿;
|
||||
/// 2. 点击题号 → onJump(index) 跳转(由 ExamPage 调 jumpToPage)。
|
||||
///
|
||||
/// 二、为什么独立成组件:
|
||||
/// 答题卡是"总览 → 跳转"的通用交互,参数化设计后可复用于
|
||||
/// 错题重做、试卷回顾等场景;不直接碰 PageController,
|
||||
/// 跳转动作通过 onJump 回调上抛,保持组件解耦。
|
||||
///
|
||||
/// 三、状态判定:answers 是 `Map<questionId, answer>`,
|
||||
/// 已答 = questionId 存在于 map(解答题空字符串算未答?——
|
||||
/// 本 Demo 约定:answer 非空字符串才算已答)。
|
||||
/// ============================================================
|
||||
class AnswerSheetDialog extends StatelessWidget {
|
||||
const AnswerSheetDialog({
|
||||
super.key,
|
||||
required this.questionIds,
|
||||
required this.answers,
|
||||
required this.currentIndex,
|
||||
required this.onJump,
|
||||
});
|
||||
|
||||
/// 题目列表(顺序即试卷顺序,用于定位题号)
|
||||
final List<int> questionIds;
|
||||
|
||||
/// 已答答案表(questionId → answer)
|
||||
final Map<int, String> answers;
|
||||
|
||||
/// 当前题索引(高亮绿色)
|
||||
final int currentIndex;
|
||||
|
||||
/// 点击题号回调(传入索引,由页面执行跳转)
|
||||
final ValueChanged<int> onJump;
|
||||
|
||||
int get _answeredCount => questionIds
|
||||
.where((id) => (answers[id] ?? '').isNotEmpty)
|
||||
.length;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 420.h),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ---------- 标题 + 已答统计 ----------
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'答题卡',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
'已答 $_answeredCount / ${questionIds.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF3B82F6),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// ---------- 图例 ----------
|
||||
Row(
|
||||
children: [
|
||||
_buildLegend(const Color(0xFF3B82F6), '已答'),
|
||||
SizedBox(width: 16.w),
|
||||
_buildLegend(const Color(0xFF22C55E), '当前'),
|
||||
SizedBox(width: 16.w),
|
||||
_buildLegend(const Color(0xFFE5E7EB), '未答'),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// ---------- 题号网格 ----------
|
||||
Flexible(
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 1.3,
|
||||
),
|
||||
itemCount: questionIds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final id = questionIds[index];
|
||||
final answered = (answers[id] ?? '').isNotEmpty;
|
||||
final isCurrent = index == currentIndex;
|
||||
return _buildCell(index, answered, isCurrent);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend(Color color, String label) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12.w,
|
||||
height: 12.w,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(3.r),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF666666)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCell(int index, bool answered, bool isCurrent) {
|
||||
final color = isCurrent
|
||||
? const Color(0xFF22C55E)
|
||||
: answered
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFFE5E7EB);
|
||||
return InkWell(
|
||||
onTap: () => onJump(index),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isCurrent || answered ? Colors.white : const Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/storage/stores/answer_record_store.dart';
|
||||
import 'package:new_learn/features/exam/answer_provider.dart';
|
||||
import 'package:new_learn/features/exam/answer_sheet_dialog.dart';
|
||||
import 'package:new_learn/features/exam/exam_question.dart';
|
||||
import 'package:new_learn/features/exam/paper_provider.dart';
|
||||
import 'package:new_learn/features/exam/question_card.dart';
|
||||
import 'package:new_learn/features/exam/question_type.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ExamPage】试卷练习页 —— 大数据滑动核心 Demo
|
||||
///
|
||||
/// 一、组件协作(本页是唯一桥接 Riverpod 的地方):
|
||||
/// paperProvider → `AsyncValue<List<Question>>`:loading/error/data 三态
|
||||
/// answerProvider → `Map<int, String>`:当前全部答案,ref.watch 响应式
|
||||
/// QuestionCard → 纯展示组件,props 传题 + 答案 + 作答回调
|
||||
///
|
||||
/// 二、PageView.builder 大数据性能(对应 Vue3 swiper 方案分析):
|
||||
/// - builder 惰性构建:只构建视口内 ±1 页(cacheExtent),
|
||||
/// 100 道题传入也只是个 List,滑出的页面 Element 自动回收;
|
||||
/// - allowImplicitScrolling: true → 预构建相邻页,滑动不掉帧;
|
||||
/// - ValueKey(questionId) → 页面复用时按题号隔离,杜绝串题。
|
||||
///
|
||||
/// 三、续答链路(进卷 → 作答 → 交卷):
|
||||
/// 进卷 initState → AnswerRecordStore.findByPaper 读回 →
|
||||
/// 注入 answerProvider(有记录则 SnackBar 提示已恢复);
|
||||
/// 作答 QuestionCard 回调 → answer() 更新内存 + 异步写 Isar;
|
||||
/// 交卷 确认 → 判分统计 → deleteByPaper 清理 → clear 内存。
|
||||
///
|
||||
/// 四、坑:
|
||||
/// 1. restore 是异步的,首次 build 时答案可能还没回来 →
|
||||
/// 注入后 provider 变化自动重建页面(响应式),无需手动刷新;
|
||||
/// 2. 交卷接口失败不能删本地记录(防"没交成但答案没了"),
|
||||
/// 本 Demo 模拟提交成功后统一清理。
|
||||
/// ============================================================
|
||||
class ExamPage extends ConsumerStatefulWidget {
|
||||
const ExamPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ExamPage> createState() => _ExamPageState();
|
||||
}
|
||||
|
||||
class _ExamPageState extends ConsumerState<ExamPage> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentIndex = 0;
|
||||
|
||||
/// 续答恢复后要定位到的题号(null = 不跳转,只定位一次)
|
||||
int? _jumpToQuestion;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_restoreProgress();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 【进卷恢复】从 Isar 读回本卷已答记录 → 注入 answerProvider
|
||||
/// 并定位到最后作答的题(按 updatedAt 最新一条)。
|
||||
///
|
||||
/// 为什么还要定位:恢复的只是"答案数据",PageView 初始还在第 1 题,
|
||||
/// 不定位的话用户每次进卷都要手动滑回上次位置(本 Bug 修复点)。
|
||||
Future<void> _restoreProgress() async {
|
||||
final records = await AnswerRecordStore.instance.findByPaper(demoPaperId);
|
||||
if (!mounted || records.isEmpty) return;
|
||||
final saved = <int, String>{};
|
||||
int? lastQuestionId;
|
||||
DateTime? lastAt;
|
||||
for (final r in records) {
|
||||
final qid = r.questionId as int;
|
||||
saved[qid] = (r.answer as String?) ?? '';
|
||||
// 记录最后作答的题:updatedAt 最新的一条(重复作答会覆盖更新时间)
|
||||
final at = r.updatedAt as DateTime?;
|
||||
if (at != null && (lastAt == null || at.isAfter(lastAt))) {
|
||||
lastAt = at;
|
||||
lastQuestionId = qid;
|
||||
}
|
||||
}
|
||||
ref.read(answerProvider.notifier).restore(saved);
|
||||
_jumpToQuestion = lastQuestionId;
|
||||
_tryJumpAfterRestore(); // 若题目已加载完成(异步竞争),这里直接跳
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已恢复上次答题进度(${saved.length} 题),定位到第 $lastQuestionId 题'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 定位到最后作答的题:restore 与题目加载谁先完成都能跳转
|
||||
void _tryJumpAfterRestore() {
|
||||
final target = _jumpToQuestion;
|
||||
if (target == null) return;
|
||||
final questions = ref.read(paperProvider).value;
|
||||
if (questions == null) return; // 题目还没加载完,等 build data 分支再试
|
||||
final index = questions.indexWhere((q) => q.questionId == target);
|
||||
if (index < 0) return;
|
||||
_jumpToQuestion = null; // 只定位一次
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// 首帧后跳转:此时 PageView 已 attach,jumpToPage 才安全
|
||||
if (!mounted || !_pageController.hasClients) return;
|
||||
_pageController.jumpToPage(index);
|
||||
});
|
||||
}
|
||||
|
||||
/// 【交卷】确认 → 清理本地进度 → 判分统计
|
||||
Future<void> _submit(List<ExamQuestion> questions) async {
|
||||
final answers = ref.read(answerProvider);
|
||||
final total = questions.length;
|
||||
final answered =
|
||||
questions.where((q) => (answers[q.questionId] ?? '').isNotEmpty).length;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认交卷?'),
|
||||
content: Text('已答 $answered / $total 题,未答 ${total - answered} 题。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('再检查一下'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('交卷'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
// 模拟提交接口成功 → 清理本地进度(防止下次进卷恢复旧答案)
|
||||
await AnswerRecordStore.instance.deleteByPaper(demoPaperId);
|
||||
ref.read(answerProvider.notifier).clear();
|
||||
if (!mounted) return;
|
||||
|
||||
// 判分:选择/判断自动比对;解答题不算分(需人工批改)
|
||||
final rightCount = questions.where((q) {
|
||||
if (q.type == QuestionType.essay) return false; // 解答题跳过
|
||||
final user = answers[q.questionId];
|
||||
return user != null && user == q.answer;
|
||||
}).length;
|
||||
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('交卷成功'),
|
||||
content: Text(
|
||||
'自动判分:答对 $rightCount / ${total - answered} 题(客观题)\n'
|
||||
'主观题需人工批改,本地进度已清理,下次进卷重新作答。',
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 打开答题卡
|
||||
void _openAnswerSheet(List<ExamQuestion> questions) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AnswerSheetDialog(
|
||||
questionIds: [for (final q in questions) q.questionId],
|
||||
answers: ref.read(answerProvider),
|
||||
currentIndex: _currentIndex,
|
||||
onJump: (index) {
|
||||
Navigator.pop(ctx);
|
||||
_pageController.jumpToPage(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final paperAsync = ref.watch(paperProvider);
|
||||
final answers = ref.watch(answerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('试卷练习'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: paperAsync.hasValue
|
||||
? () => _submit(paperAsync.value!)
|
||||
: null,
|
||||
child: const Text(
|
||||
'交卷',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: paperAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('试卷加载失败:$e', style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
data: (questions) {
|
||||
// 续答恢复可能晚于题目加载完成:data 分支兜底再触发一次定位
|
||||
_tryJumpAfterRestore();
|
||||
return Column(
|
||||
children: [
|
||||
_buildProgressBar(questions, answers),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: questions.length,
|
||||
// 预构建相邻页:滑动时目标页已就绪,不掉帧
|
||||
allowImplicitScrolling: true,
|
||||
onPageChanged: (i) => setState(() => _currentIndex = i),
|
||||
itemBuilder: (context, i) {
|
||||
final q = questions[i];
|
||||
return QuestionCard(
|
||||
// 按题号隔离:PageView 页面复用时杜绝串题
|
||||
key: ValueKey(q.questionId),
|
||||
question: q,
|
||||
answer: answers[q.questionId],
|
||||
onAnswer: (v) =>
|
||||
ref.read(answerProvider.notifier).answer(q.questionId, v),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildBottomBar(questions),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 顶部进度条:已答 X / N + 当前题号
|
||||
Widget _buildProgressBar(List<ExamQuestion> questions, Map<int, String> answers) {
|
||||
final total = questions.length;
|
||||
final answered =
|
||||
questions.where((q) => (answers[q.questionId] ?? '').isNotEmpty).length;
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 10.h),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'第 ${_currentIndex + 1} / $total 题',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'已答 $answered 题',
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
child: LinearProgressIndicator(
|
||||
value: total == 0 ? 0 : answered / total,
|
||||
minHeight: 6,
|
||||
backgroundColor: const Color(0xFFF0F2F5),
|
||||
valueColor: const AlwaysStoppedAnimation(Color(0xFF3B82F6)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部导航:上一题 / 答题卡 / 下一题
|
||||
Widget _buildBottomBar(List<ExamQuestion> questions) {
|
||||
final isFirst = _currentIndex == 0;
|
||||
final isLast = _currentIndex == questions.length - 1;
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Color(0xFFF0F2F5))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isFirst
|
||||
? null
|
||||
: () => _pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
icon: const Icon(Icons.chevron_left, size: 18),
|
||||
label: const Text('上一题'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () => _openAnswerSheet(questions),
|
||||
icon: const Icon(Icons.grid_view, size: 18),
|
||||
label: const Text('答题卡'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: isLast
|
||||
? null
|
||||
: () => _pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
icon: const Icon(Icons.chevron_right, size: 18),
|
||||
label: const Text('下一题'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:new_learn/features/exam/question_type.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ExamQuestion】试卷题目接口模型(纯 Dart,无 Isar 依赖)
|
||||
///
|
||||
/// 一、为什么不能直接用 Isar 的 Question 模型(web 编译约束):
|
||||
/// Isar 模型生成的 *.g.dart 含 64 位 schema 哈希常量,
|
||||
/// dart2js 无法精确表示 → web 编译直接失败(本项目已踩坑)。
|
||||
/// 业务代码 import Isar 模型 = 把它拉进 web 编译图。
|
||||
/// 所以试卷功能用本纯 Dart 模型(接口 JSON 解析),
|
||||
/// WrongQuestionStore 注释的约定:"页面永远不要 import 模型"。
|
||||
///
|
||||
/// 二、与 Isar Question 字段对应:
|
||||
/// questionId/title/answer/analysis 一一对应;
|
||||
/// options 直接用 `List<String>`(接口 JSON 是数组),
|
||||
/// Isar 侧存 JSON 字符串只是本地存储的简化写法,语义相同。
|
||||
/// ============================================================
|
||||
class ExamQuestion {
|
||||
const ExamQuestion({
|
||||
required this.questionId,
|
||||
required this.title,
|
||||
required this.options,
|
||||
required this.answer,
|
||||
required this.type,
|
||||
required this.analysis,
|
||||
});
|
||||
|
||||
/// 服务器题号(答案 Map 的 key,续答恢复的关联字段)
|
||||
final int questionId;
|
||||
|
||||
/// 题干(HTML 富文本,RichTextContent 渲染)
|
||||
final String title;
|
||||
|
||||
/// 选项(选择/判断题有值,解答题空列表)
|
||||
final List<String> options;
|
||||
|
||||
/// 正确答案(多选 "A,C" 逗号拼接,判断 "对"/"错",解答为参考答案)
|
||||
final String answer;
|
||||
|
||||
/// 题型(渲染分发的依据)
|
||||
final QuestionType type;
|
||||
|
||||
/// 解析(交卷后查看答案用)
|
||||
final String analysis;
|
||||
|
||||
/// 接口 JSON → 模型(字段缺失兜底空值,防御脏数据)
|
||||
factory ExamQuestion.fromJson(Map<String, dynamic> json) {
|
||||
return ExamQuestion(
|
||||
questionId: json['questionId'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
options: (json['options'] as List?)?.cast<String>() ?? const [],
|
||||
answer: json['answer'] as String? ?? '',
|
||||
type: QuestionType.fromCode(json['type'] as int?),
|
||||
analysis: json['analysis'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:new_learn/features/exam/exam_question.dart';
|
||||
import 'package:new_learn/features/exam/question_type.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【paperProvider】试卷题目数据源 —— Riverpod 内存主状态
|
||||
///
|
||||
/// 一、架构定位(对应方案分析结论):
|
||||
/// 题目是"会话数据"不是"资产":进卷拉一次、退出即弃,
|
||||
/// 不落 Isar(Isar 只负责跨会话的答案进度,见 answer_provider)。
|
||||
/// 100 题内存占用 ≈ 1MB,PageView.builder 懒加载按需构建,
|
||||
/// 大数组传入无性能压力。
|
||||
///
|
||||
/// 二、mock 模式(对标 Vue3 内联 mock 体验):
|
||||
/// const bool mock = true 时直接本地生成题目(Future.delayed
|
||||
/// 模拟接口延迟),改 false 后接真实接口(DioClient),
|
||||
/// 一行开关切换,热重启即生效。
|
||||
///
|
||||
/// 三、paperSizeProvider:demo 专用,控制生成题数(50/100/200),
|
||||
/// paperProvider watch 它 → 改变自动重新加载(Riverpod 响应式)。
|
||||
/// ============================================================
|
||||
|
||||
/// Demo 专用:试卷题目数量(50/100/200,入口页选择)
|
||||
class PaperSizeNotifier extends Notifier<int> {
|
||||
@override
|
||||
int build() => 100;
|
||||
|
||||
void setSize(int size) => state = size;
|
||||
}
|
||||
|
||||
final paperSizeProvider =
|
||||
NotifierProvider<PaperSizeNotifier, int>(PaperSizeNotifier.new);
|
||||
|
||||
/// 试卷题目加载器
|
||||
final paperProvider = AsyncNotifierProvider<PaperNotifier, List<ExamQuestion>>(
|
||||
PaperNotifier.new,
|
||||
);
|
||||
|
||||
class PaperNotifier extends AsyncNotifier<List<ExamQuestion>> {
|
||||
/// 接口 mock 开关:true = 本地生成;false = 接真实接口(DioClient)
|
||||
static const bool mock = true;
|
||||
|
||||
@override
|
||||
Future<List<ExamQuestion>> build() async {
|
||||
final size = ref.watch(paperSizeProvider);
|
||||
if (mock) {
|
||||
// 模拟接口延迟:首屏展示 loading,验证加载态
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
return MockExamData.generate(size);
|
||||
}
|
||||
// 真实接口:GET /api/v1/Paper/{paperId}/Questions
|
||||
// final resp = await DioClient.instance.get('/api/v1/Paper/1/Questions');
|
||||
// return (resp.data['list'] as List).map(Question.fromJson).toList();
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【MockExamData】大数据模拟生成器(100 题 × 4 题型)
|
||||
///
|
||||
/// 模拟真实试卷的题目构成:单选 / 多选 / 判断 / 解答 循环分布,
|
||||
/// 题干带少量 HTML 标签(RichTextContent 富文本渲染路径),
|
||||
/// 用于验证 PageView.builder 大数据滑动性能与题型分发。
|
||||
/// ============================================================
|
||||
class MockExamData {
|
||||
/// 学科题材池(按题号轮换,避免题目千篇一律)
|
||||
static const List<(String, String)> _stems = [
|
||||
('数学', '计算 12 × 8 = ?'),
|
||||
('语文', '下列词语书写<u>完全正确</u>的一项是?'),
|
||||
('英语', '— What does he do?<br>— He is a(n) <b>____</b>.'),
|
||||
('科学', '植物进行光合作用的主要器官是?'),
|
||||
('常识', '地球是太阳系中最大的行星。'),
|
||||
('语文', '“落霞与孤鹜齐飞”出自哪篇名作?'),
|
||||
('数学', '一个三角形最多有几个直角?'),
|
||||
('英语', '下列单词中发音与其他三项不同的一项是?'),
|
||||
];
|
||||
|
||||
static List<ExamQuestion> generate(int count) {
|
||||
return List.generate(count, (i) {
|
||||
final (subject, stem) = _stems[i % _stems.length];
|
||||
return switch (i % 4) {
|
||||
// 0 → 单选(4 个选项)
|
||||
0 => ExamQuestion(
|
||||
questionId: i + 1,
|
||||
type: QuestionType.single,
|
||||
title: '<p><b>【$subject】</b> 第 ${i + 1} 题</p><p>$stem</p>',
|
||||
options: const ['选项 A 的内容', '选项 B 的内容', '选项 C 的内容', '选项 D 的内容'],
|
||||
answer: 'A',
|
||||
analysis: '本题考察基础知识点,正确答案为 A。',
|
||||
),
|
||||
// 1 → 多选(答案两个字母)
|
||||
1 => ExamQuestion(
|
||||
questionId: i + 1,
|
||||
type: QuestionType.multi,
|
||||
title:
|
||||
'<p><b>【$subject】</b> 第 ${i + 1} 题(多选)</p><p>$stem</p>',
|
||||
options: const ['选项 A 的内容', '选项 B 的内容', '选项 C 的内容', '选项 D 的内容'],
|
||||
answer: 'A,C',
|
||||
analysis: '本题有两个正确答案:A 与 C。',
|
||||
),
|
||||
// 2 → 判断(对 / 错)
|
||||
2 => ExamQuestion(
|
||||
questionId: i + 1,
|
||||
type: QuestionType.judge,
|
||||
title:
|
||||
'<p><b>【$subject】</b> 第 ${i + 1} 题(判断)</p><p>$stem</p>',
|
||||
options: const ['对', '错'],
|
||||
answer: i % 6 == 0 ? '错' : '对',
|
||||
analysis: '依据教材定义判断,答案为 ${i % 6 == 0 ? "错" : "对"}。',
|
||||
),
|
||||
// 3 → 解答题(文本输入)
|
||||
_ => ExamQuestion(
|
||||
questionId: i + 1,
|
||||
type: QuestionType.essay,
|
||||
title:
|
||||
'<p><b>【$subject】</b> 第 ${i + 1} 题(解答)</p><p>$stem</p>',
|
||||
options: const [],
|
||||
answer: '水有三种状态:液态、固态、气态。',
|
||||
analysis: '解答题需人工批改,参考答案:水有三种状态。',
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/features/exam/exam_question.dart';
|
||||
import 'package:new_learn/features/exam/question_type.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【QuestionCard】题目卡片 —— 纯展示组件(业务解耦)
|
||||
///
|
||||
/// 一、设计原则:
|
||||
/// 组件不碰 Riverpod,只接收 question + 当前答案 + 回调,
|
||||
/// 由 ExamPage 负责桥接 provider。这样组件可复用、可单测,
|
||||
/// 换状态管理方案不用改组件。
|
||||
///
|
||||
/// 二、题型分发(本项目核心模式):
|
||||
/// 按 question.type switch 到三个子视图:
|
||||
/// ChoiceQuestionView(单选/多选,共用选项布局,toggle 逻辑区分)
|
||||
/// JudgeQuestionView(判断,对/错两按钮)
|
||||
/// EssayQuestionView(解答,文本输入 + 防抖提交)
|
||||
/// 新增题型只需加一个枚举值 + 一个视图,QuestionCard 加一个 case。
|
||||
///
|
||||
/// 三、题干渲染:
|
||||
/// RichTextContent 纯 widget 渲染 HTML(非 WebView),
|
||||
/// 富文本解析发生在每次 build —— 真实项目可在此接入预解析缓存
|
||||
/// (进卷时批量解析一次 HTML,按 questionId 缓存解析结果)。
|
||||
/// ============================================================
|
||||
class QuestionCard extends StatelessWidget {
|
||||
const QuestionCard({
|
||||
super.key,
|
||||
required this.question,
|
||||
required this.answer,
|
||||
required this.onAnswer,
|
||||
});
|
||||
|
||||
final ExamQuestion question;
|
||||
final String? answer;
|
||||
final ValueChanged<String> onAnswer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = question.type;
|
||||
return Container(
|
||||
margin: EdgeInsets.all(16.w),
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(color: const Color(0xFFEEF1F5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ---------- 题型标签 + 题号 ----------
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColor(type).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
),
|
||||
child: Text(
|
||||
type.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
color: _typeColor(type),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'第 ${question.questionId} 题',
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// ---------- 题干(富文本) ----------
|
||||
RichTextContent(
|
||||
html: question.title,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.6,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
// ---------- 按题型分发 ----------
|
||||
switch (type) {
|
||||
QuestionType.single => ChoiceQuestionView(
|
||||
options: question.options,
|
||||
multiSelect: false,
|
||||
answer: answer,
|
||||
onAnswer: onAnswer,
|
||||
),
|
||||
QuestionType.multi => ChoiceQuestionView(
|
||||
options: question.options,
|
||||
multiSelect: true,
|
||||
answer: answer,
|
||||
onAnswer: onAnswer,
|
||||
),
|
||||
QuestionType.judge => JudgeQuestionView(
|
||||
answer: answer,
|
||||
onAnswer: onAnswer,
|
||||
),
|
||||
QuestionType.essay => EssayQuestionView(
|
||||
answer: answer,
|
||||
onAnswer: onAnswer,
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Color _typeColor(QuestionType type) {
|
||||
return switch (type) {
|
||||
QuestionType.single => const Color(0xFF3B82F6),
|
||||
QuestionType.multi => const Color(0xFF8B5CF6),
|
||||
QuestionType.judge => const Color(0xFFF59E0B),
|
||||
QuestionType.essay => const Color(0xFF10B981),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【ChoiceQuestionView】选择题(单选/多选共用)
|
||||
///
|
||||
/// 受控组件:选中状态完全来自外部 answer,自己不留状态。
|
||||
/// 答案格式:"A"(单选)/ "A,C"(多选,升序逗号拼接)。
|
||||
/// ============================================================
|
||||
class ChoiceQuestionView extends StatelessWidget {
|
||||
const ChoiceQuestionView({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.multiSelect,
|
||||
required this.answer,
|
||||
required this.onAnswer,
|
||||
});
|
||||
|
||||
final List<String> options;
|
||||
final bool multiSelect;
|
||||
final String? answer;
|
||||
final ValueChanged<String> onAnswer;
|
||||
|
||||
void _onTap(int index) {
|
||||
final letter = String.fromCharCode(65 + index); // 0→A, 1→B...
|
||||
if (!multiSelect) {
|
||||
onAnswer(letter);
|
||||
return;
|
||||
}
|
||||
// 多选:在选中集合中 toggle,再升序拼接
|
||||
final selected = answer == null
|
||||
? <String>{}
|
||||
: answer!.split(',').where((s) => s.isNotEmpty).toSet();
|
||||
if (!selected.add(letter)) selected.remove(letter);
|
||||
final sorted = selected.toList()..sort();
|
||||
onAnswer(sorted.join(','));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedSet = answer == null
|
||||
? <String>{}
|
||||
: answer!.split(',').where((s) => s.isNotEmpty).toSet();
|
||||
return Column(
|
||||
children: [
|
||||
for (int i = 0; i < options.length; i++) ...[
|
||||
_buildOption(i, selectedSet),
|
||||
if (i < options.length - 1) SizedBox(height: 10.h),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOption(int index, Set<String> selectedSet) {
|
||||
final letter = String.fromCharCode(65 + index);
|
||||
final isSelected = selectedSet.contains(letter);
|
||||
return InkWell(
|
||||
onTap: () => _onTap(index),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFEFF6FF) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFFE5E7EB),
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26.w,
|
||||
height: 26.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected ? const Color(0xFF3B82F6) : Colors.white,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? Colors.white : const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
options[index],
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (multiSelect)
|
||||
Icon(
|
||||
isSelected ? Icons.check_circle : Icons.circle_outlined,
|
||||
size: 18,
|
||||
color: isSelected
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFFD1D5DB),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【JudgeQuestionView】判断题(对/错两按钮)
|
||||
/// 答案格式:"对" / "错"(与 Question.answer 存储约定一致)。
|
||||
/// ============================================================
|
||||
class JudgeQuestionView extends StatelessWidget {
|
||||
const JudgeQuestionView({
|
||||
super.key,
|
||||
required this.answer,
|
||||
required this.onAnswer,
|
||||
});
|
||||
|
||||
final String? answer;
|
||||
final ValueChanged<String> onAnswer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
for (final value in const ['对', '错']) ...[
|
||||
Expanded(
|
||||
child: _buildJudgeButton(value),
|
||||
),
|
||||
if (value == '对') SizedBox(width: 12.w),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJudgeButton(String value) {
|
||||
final isSelected = answer == value;
|
||||
return InkWell(
|
||||
onTap: () => onAnswer(value),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (value == '对' ? const Color(0xFFE8F7EE) : const Color(0xFFFDECEA))
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? (value == '对' ? const Color(0xFF22C55E) : const Color(0xFFEF4444))
|
||||
: const Color(0xFFE5E7EB),
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
value == '对' ? Icons.check_circle : Icons.cancel,
|
||||
size: 22,
|
||||
color: isSelected
|
||||
? (value == '对' ? const Color(0xFF22C55E) : const Color(0xFFEF4444))
|
||||
: const Color(0xFF9CA3AF),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【EssayQuestionView】解答题(文本输入 + 300ms 防抖提交)
|
||||
///
|
||||
/// 防抖原因:onChanged 每敲一个字都会触发 provider 更新 + Isar 写库,
|
||||
/// 300ms 空闲后合并为一次提交,减少高频写盘。
|
||||
/// 注意:组件是受控回填的 —— 外部 answer 变化(续答恢复)时
|
||||
/// didUpdateWidget 同步 controller 文本,但不打断用户正在输入。
|
||||
/// ============================================================
|
||||
class EssayQuestionView extends StatefulWidget {
|
||||
const EssayQuestionView({
|
||||
super.key,
|
||||
required this.answer,
|
||||
required this.onAnswer,
|
||||
});
|
||||
|
||||
final String? answer;
|
||||
final ValueChanged<String> onAnswer;
|
||||
|
||||
@override
|
||||
State<EssayQuestionView> createState() => _EssayQuestionViewState();
|
||||
}
|
||||
|
||||
class _EssayQuestionViewState extends State<EssayQuestionView> {
|
||||
late final TextEditingController _controller;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.answer ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EssayQuestionView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 外部答案变化(如续答恢复)且与当前输入不一致时才回填,
|
||||
// 避免覆盖用户正在输入的内容
|
||||
if (widget.answer != null && widget.answer != _controller.text) {
|
||||
_controller.text = widget.answer!;
|
||||
}
|
||||
}
|
||||
|
||||
void _onChanged(String text) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
widget.onAnswer(text.trim());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF9FAFB),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
onChanged: _onChanged,
|
||||
maxLines: 5,
|
||||
minLines: 3,
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入你的解答...',
|
||||
hintStyle: const TextStyle(color: Color(0xFFB0B7C3)),
|
||||
contentPadding: EdgeInsets.all(12.w),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
style: TextStyle(fontSize: 14.sp, height: 1.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// ============================================================
|
||||
/// 【QuestionType】题型枚举 —— 试卷渲染分发的唯一依据
|
||||
///
|
||||
/// 与 Question 模型的 type 字段(int)一一对应:
|
||||
/// 1 单选 / 2 多选 / 3 判断 / 4 解答
|
||||
/// QuestionCard 拿到 question 后 switch type 分发到对应视图组件。
|
||||
///
|
||||
/// 为什么用枚举而不是裸 int:
|
||||
/// 枚举自带 label(UI 标签)、答案格式约定(选择 "A,B" / 判断 "对" /
|
||||
/// 解答文本),页面不用到处散落魔法数字,加新题型只改这一个文件。
|
||||
/// ============================================================
|
||||
enum QuestionType {
|
||||
single(1, '单选题'),
|
||||
multi(2, '多选题'),
|
||||
judge(3, '判断题'),
|
||||
essay(4, '解答题');
|
||||
|
||||
const QuestionType(this.code, this.label);
|
||||
|
||||
/// Isar 存储值(Question.type)
|
||||
final int code;
|
||||
final String label;
|
||||
|
||||
/// 从存储 int 转枚举(非法值兜底为单选,防御脏数据)
|
||||
static QuestionType fromCode(int? code) {
|
||||
return QuestionType.values.firstWhere(
|
||||
(t) => t.code == code,
|
||||
orElse: () => QuestionType.single,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【答案格式约定】统一 String 存储
|
||||
///
|
||||
/// 单选 → "A"(一个字母)
|
||||
/// 多选 → "A,B"(字母按升序逗号拼接,如 "A,C")
|
||||
/// 判断 → "对" / "错"
|
||||
/// 解答 → 用户输入的纯文本
|
||||
/// QuestionCard 各题型视图按约定编码/解码,交卷时按约定序列化上报。
|
||||
/// ============================================================
|
||||
Reference in New Issue
Block a user