3 Commits
Author SHA1 Message Date
chenshuainan e63950afe8 合并路由文件 2026-09-28 20:14:17 +08:00
chenshuainan 5f53bd8f94 更新 2026-09-28 20:12:34 +08:00
chenshuainan 847986f861 feat: 新增过渡页面路由、上传组件及完善跳转逻辑
- 为conquerPackage模块添加过渡页面路由并实现页面跳转逻辑
- 新增通用多图上传组件Upload,支持多种模式与图片操作功能
- 新增任务相关的API及Model基础文件
- 注释课前课中页面的测试试题内容
2026-09-14 09:09:25 +08:00
64 changed files with 15167 additions and 92 deletions
+76
View File
@@ -0,0 +1,76 @@
Resolving dependencies...
Downloading packages...
_fe_analyzer_shared 96.0.0 (108.0.0 available)
analyzer 10.2.0 (14.4.0 available)
archive 4.2.0 (4.3.0 available)
build 4.0.7 (4.0.11 available)
build_runner 2.15.1 (2.16.1 available)
cached_network_image 3.4.1 (4.0.2 available)
cached_network_image_platform_interface 4.1.1 (5.0.2 available)
cached_network_image_web 1.3.1 (2.0.2 available)
camera_android_camerax 0.7.4+7 (0.7.5 available)
camera_avfoundation 0.10.3 (0.11.0 available)
camera_platform_interface 2.13.1 (2.14.0 available)
camera_web 0.3.5+5 (0.3.5+6 available)
chewie 1.14.0 (1.17.2 available)
clock 1.1.2 (1.1.3 available)
code_assets 1.2.1 (2.1.0 available)
cross_file 0.3.5+5 (0.4.0 available)
dart_style 3.1.7 (3.1.13 available)
dbus 0.7.15 (0.8.0 available)
equatable 2.1.0 (3.0.0 available)
flutter_cache_manager 3.4.2 (3.4.5 available)
flutter_local_notifications 22.3.0 (22.3.1 available)
flutter_secure_storage 10.3.1 (11.2.0 available)
flutter_secure_storage_darwin 0.3.2 (0.4.3 available)
flutter_secure_storage_linux 3.0.2 (3.0.3 available)
flutter_secure_storage_platform_interface 2.0.3 (2.1.1 available)
flutter_widget_from_html_core 0.17.3 (0.17.4 available)
go_router 17.5.0 (18.0.1 available)
hooks 2.0.2 (2.2.0 available)
image 4.9.2 (4.10.1 available)
image_picker_android 0.8.13+22 (0.8.13+23 available)
image_picker_ios 0.8.13+7 (0.8.13+8 available)
internet_connection_checker_plus 3.1.1 (3.1.2 available)
matcher 0.12.19 (0.12.20 available)
material_color_utilities 0.13.0 (0.13.1 available)
meta 1.18.0 (1.19.0 available)
nm 0.5.0 (0.6.0 available)
objective_c 9.5.0 (9.6.0 available)
octo_image 2.1.0 (2.1.1 available)
package_config 2.2.0 (3.0.0 available)
package_info_plus 10.0.0 (10.2.1 available)
permission_handler 11.4.0 (13.0.2 available)
permission_handler_android 12.1.0 (14.1.0 available)
permission_handler_apple 9.6.1 (9.6.2 available)
platform 3.1.6 (3.2.0 available)
record_use 0.6.0 (1.1.1 available)
share_plus 13.2.0 (13.3.0 available)
source_gen 4.2.4 (4.3.0 available)
sqflite 2.4.3 (2.4.4 available)
sqflite_android 2.4.3 (2.4.4 available)
sqflite_common 2.5.11 (2.5.13 available)
sqflite_darwin 2.4.3+1 (2.4.4 available)
sqflite_platform_interface 2.4.1 (2.4.2 available)
stack_trace 1.12.1 (1.12.2 available)
synchronized 3.4.1+2 (3.4.2 available)
test_api 0.7.11 (0.7.14 available)
vector_math 2.2.0 (2.4.3 available)
wakelock_plus 1.6.0 (1.8.0 available)
webdriver 3.1.0 (3.2.0 available)
wechat_assets_picker 9.8.0 (10.1.3 available)
Got dependencies!
59 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
Analyzing controllers...
info - The import of 'package:new_learn/core/utils/question/question_parser_base.dart' is unnecessary because all of the used elements are also provided by the import of 'package:new_learn/core/utils/question/question_parser.dart'. Try removing the import directive - lib\features\conquerpackage\classexercises\controllers\exercise_load.dart:16:8 - unnecessary_import
info - The import of 'package:new_learn/core/utils/question/question_parser_base.dart' is unnecessary because all of the used elements are also provided by the import of 'package:new_learn/core/utils/question/question_parser.dart'. Try removing the import directive - lib\features\conquerpackage\classexercises\controllers\exercise_submit.dart:23:8 - unnecessary_import
flutter : 2 issues found. (ran in 3.2s)
At line:15 char:2543
+ ... pp_Flutter; flutter analyze lib/features/conquerPackage/classExercise ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (2 issues found. (ran in 3.2s):String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -20,8 +20,7 @@ import 'package:new_learn/core/storage/user_storage.dart';
/// updateUserAvatar → 更改用户头像
///
/// 二、后续继续写(对齐 Vue,用到再加):
/// checkStudyPackStatus / setChangeUserOnline
/// getUserPointLevel / UpdateGuideStatus / UpdateReportStatus
/// checkStudyPackStatus / setChangeUserOnline / getUserPointLevel
///
/// 三、高频坑:
/// 1. 登录密码 MD5 加密(对齐 Vue stringToMd5),提交前在 Api 层完成;
@@ -157,6 +156,15 @@ class UserApi {
await DioClient.instance.post('/api/v1/User/ChangeShowAfterReport');
}
/// 更新攻克报告引导状态(报告页引导只出现一次,默认 false,触发后变 true)
/// → POST /api/v1/User/ChangeShowGuide
///
/// 对齐 Vue api/user/index.ts 的 UpdateGuideStatus(),
/// 在攻克报告页 GuideModal 关闭时调用。
static Future<void> changeShowGuide() async {
await DioClient.instance.post('/api/v1/User/ChangeShowGuide');
}
/// MD5 加密(UniApp stringToMd5 的等价实现)
static String _md5(String text) {
return md5.convert(utf8.encode(text)).toString();
@@ -22,6 +22,10 @@ import 'package:new_learn/core/network/dio_client.dart';
/// —— 获取攻克报告(view 模式数据源)
/// GET /api/v1/StudyPack/GetReport/{recordId}
///
/// getRecord
/// —— 获取攻克记录信息(过渡页使用)
/// GET /api/v1/StudyPack/GetRecord/{recordId}
///
/// 注意:
/// - 页面只调这里,不直接碰 Dio;
/// - 异常不在 catch,交页面统一处理;
@@ -77,4 +81,17 @@ class BeforeDuringClassApi {
}
return const HandoutListResponse();
}
/// 获取攻克记录信息 → GET /api/v1/StudyPack/GetRecord/{recordId}
///
/// 过渡页使用:获取攻克包名称、攻克标准、是否有题目等。
Future<OvercomeRecordInfo> getRecord(String recordId) async {
final data = await DioClient.instance.get(
'/api/v1/StudyPack/GetRecord/$recordId',
);
if (data is Map<String, dynamic>) {
return OvercomeRecordInfo.fromJson(data);
}
return const OvercomeRecordInfo();
}
}
@@ -154,6 +154,34 @@ class HandoutItem {
}
}
/// 攻克记录信息(过渡页使用,对标 Vue GetRecordData 返回值)
///
/// 接口:GET /api/v1/StudyPack/GetRecord/{recordId}
/// 用于过渡页(课前/课中/课后)展示攻克包名称、攻克标准、是否有题目等。
class OvercomeRecordInfo {
const OvercomeRecordInfo({
this.id = '',
this.name = '',
this.overcomeStandard = 0,
this.hasQuestion = false,
});
final String id; // 记录 id
final String name; // 攻克包名称
final int overcomeStandard; // 攻克标准(百分比 0-100)
final bool hasQuestion; // 是否有题目
factory OvercomeRecordInfo.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return OvercomeRecordInfo(
id: payload['id']?.toString() ?? '',
name: payload['name'] as String? ?? '',
overcomeStandard: _parseInt(payload['overcomeStandard']),
hasQuestion: payload['hasQuestion'] as bool? ?? false,
);
}
}
/// 讲义列表响应(Handout 接口返回的列表)
class HandoutListResponse {
const HandoutListResponse({this.items = const []});
@@ -16,6 +16,7 @@ class CourseRecord {
this.accuracyRate = 0,
this.status = 0,
this.statusName = '',
this.overcomeTimeSecond = 0,
});
final int id; // 记录 id
@@ -23,6 +24,7 @@ class CourseRecord {
final int accuracyRate; // 正确率(0-100)
final int status; // 状态:0 未开始 / 1 进行中 / 2 成功 / 3 失败 等(按后端枚举)
final String statusName; // 状态名称
final int overcomeTimeSecond; // 攻克用时(秒)
factory CourseRecord.fromJson(Map<String, dynamic> json) {
return CourseRecord(
@@ -31,6 +33,7 @@ class CourseRecord {
accuracyRate: _parseInt(json['accuracyRate']),
status: _parseInt(json['status']),
statusName: json['statusName'] as String? ?? '',
overcomeTimeSecond: _parseInt(json['overcomeTimeSecond']),
);
}
}
@@ -0,0 +1,107 @@
import 'package:new_learn/core/api/conquerPackage/classExercises/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【课堂习题模块接口】
///
/// 对标 Vue 端 api/task/index.ts、api/userCollect/index.ts:
///
/// GetRecordData GET /api/v1/StudyPack/GetRecord/{recordId}
/// GetListByRecordQuestion GET /api/v1/Question/GetListByRecord/{recordId}
/// QuestionRenewTime POST /api/v1/Question/RenewTime
/// PushAIGrading POST /api/v1/Question/PushAIGrading
/// RecordSubmit POST /api/v1/StudyPack/RecordSubmit
/// GetSubmitReport GET /api/v1/StudyPack/GetReport/{packId}(60s 超时)
/// AddUserCollect POST /api/v1/UserCollect/AddUserCollect
/// CancleCollect POST /api/v1/UserCollect/CancleCollect
/// Feedback POST /api/v1/Question/Feedback
///
/// 说明:
/// - RenewTime / PushAIGrading / RecordSubmit 不脱壳,返回原始 data,
/// 由提交控制器读取 code / message / aiGradingCount / id;
/// - 异常统一抛 ApiException,交页面/控制器处理。
/// ============================================================
class ClassExercisesApi {
ClassExercisesApi._();
static final ClassExercisesApi instance = ClassExercisesApi._();
/// 获取学习记录信息(edit 模式 onLoad 第一步)
Future<ClassExercisesRecord> getRecordData(String recordId) async {
final data = await DioClient.instance.get(
'/api/v1/StudyPack/GetRecord/$recordId',
);
return ClassExercisesRecord.fromJson(data);
}
/// 根据学习记录获取题目列表
Future<ClassExercisesQuestionList> getListByRecordQuestion(
String recordId,
) async {
final data = await DioClient.instance.get(
'/api/v1/Question/GetListByRecord/$recordId',
);
return ClassExercisesQuestionList.fromJson(data);
}
/// 提交单题作答 / 更新作答时长
/// body: {id, childId, courseId, userAnswer, startTime, endTime,
/// answerStatus, index?}
/// 返回原始响应(提交失败重试队列会读 requestParams/data 兜底)
Future<dynamic> questionRenewTime(Map<String, dynamic> body) {
return DioClient.instance.post('/api/v1/Question/RenewTime', data: body);
}
/// 提交 AI 图片批改
/// body: {id, childId, courseId, startTime, endTime,
/// answerImgs:[{url,thumbnailUrl}]}
Future<dynamic> pushAIGrading(Map<String, dynamic> body) {
return DioClient.instance.post(
'/api/v1/Question/PushAIGrading',
data: body,
);
}
/// 交卷
/// body: {recordId};返回 {code,message,aiGradingCount}
Future<dynamic> recordSubmit(String recordId) {
return DioClient.instance.post(
'/api/v1/StudyPack/RecordSubmit',
data: {'recordId': recordId},
);
}
/// 获取攻克报告(view 模式数据源,题目批改可能耗时,超时 60s)
Future<ClassExercisesReport> getSubmitReport(String packId) async {
final data = await DioClient.instance.get(
'/api/v1/StudyPack/GetReport/$packId',
receiveTimeout: const Duration(seconds: 60),
);
return ClassExercisesReport.fromJson(data);
}
/// 添加收藏 / 错题本
/// body: {type, packId, questionId, recordQuestionId}
/// 返回原始响应(成功时 res.id 为收藏记录 id)
Future<dynamic> addUserCollect(Map<String, dynamic> body) {
return DioClient.instance.post(
'/api/v1/UserCollect/AddUserCollect',
data: body,
);
}
/// 取消收藏 / 移出错题本
/// body: {type, id}
Future<dynamic> cancleCollect(Map<String, dynamic> body) {
return DioClient.instance.post(
'/api/v1/UserCollect/CancleCollect',
data: body,
);
}
/// 题目反馈
/// body: {id, questionId, feedbackTypes, description, images}
Future<dynamic> feedback(Map<String, dynamic> body) {
return DioClient.instance.post('/api/v1/Question/Feedback', data: body);
}
}
@@ -0,0 +1,127 @@
/// ============================================================
/// 【课堂习题模块模型】接口响应壳解析
///
/// 对标 Vue 端:
/// api/task/index.ts → GetRecordData / GetListByRecordQuestion
/// / GetSubmitReport / RecordSubmit
/// api/question/index.ts → RenewTime / PushAIGrading / Feedback
///
/// 说明:
/// 题目字段非常多且解析器(questionParser)会动态增删字段,
/// 题目列表统一以 `Map<String, dynamic>` 承载,业务侧再包 QuestionModel,
/// 全量保留后端原字段名,不做字段裁剪。
/// ============================================================
library;
/// 从响应中取业务 payload(兼容 {code,message,data:{...}} 脱壳)
Map<String, dynamic> unwrapData(dynamic resp) {
if (resp is Map<String, dynamic>) {
final data = resp['data'];
if (data is Map<String, dynamic>) return data;
return resp;
}
return <String, dynamic>{};
}
/// GET StudyPack/GetRecord/{recordId} 响应
class ClassExercisesRecord {
const ClassExercisesRecord({
this.recordId = '',
this.name = '',
this.baseSubjectId,
this.raw = const {},
});
/// 学习记录 id
final String recordId;
/// 攻克包名称(用于 NavBar 标题:`${name}专项智能攻克`)
final String name;
/// 学科 id(决定 iSFilllankItemUpload:0 输入框 / 1 图片作答)
final int? baseSubjectId;
final Map<String, dynamic> raw;
factory ClassExercisesRecord.fromJson(dynamic json) {
final data = unwrapData(json);
return ClassExercisesRecord(
recordId:
data['recordId']?.toString() ?? data['id']?.toString() ?? '',
name: data['name']?.toString() ?? '',
baseSubjectId:
int.tryParse('${data['baseSubjectId'] ?? ''}'),
raw: data,
);
}
}
/// GET Question/GetListByRecord/{recordId} 响应
class ClassExercisesQuestionList {
const ClassExercisesQuestionList({
this.items = const [],
this.raw = const {},
});
/// 原始题目列表(字段全量保留,交给 parseQuestionData 处理)
final List<Map<String, dynamic>> items;
final Map<String, dynamic> raw;
factory ClassExercisesQuestionList.fromJson(dynamic json) {
final data = unwrapData(json);
final rawList = data['questions'] ?? data['list'] ?? data['data'];
final list = <Map<String, dynamic>>[];
if (rawList is List) {
for (final item in rawList) {
if (item is Map) {
list.add(Map<String, dynamic>.from(item));
}
}
}
return ClassExercisesQuestionList(items: list, raw: data);
}
}
/// GET StudyPack/GetReport/{packId} 响应(view 模式数据源)
class ClassExercisesReport {
const ClassExercisesReport({
this.id = '',
this.name = '',
this.baseSubjectId,
this.questions = const [],
this.raw = const {},
});
/// 报告/记录 id(view 模式下作为 recordId 使用)
final String id;
final String name;
final int? baseSubjectId;
/// 报告内题目列表(字段全量保留)
final List<Map<String, dynamic>> questions;
final Map<String, dynamic> raw;
factory ClassExercisesReport.fromJson(dynamic json) {
final data = unwrapData(json);
final rawList = data['questions'];
final list = <Map<String, dynamic>>[];
if (rawList is List) {
for (final item in rawList) {
if (item is Map) {
list.add(Map<String, dynamic>.from(item));
}
}
}
return ClassExercisesReport(
id: data['id']?.toString() ?? data['recordId']?.toString() ?? '',
name: data['name']?.toString() ?? '',
baseSubjectId: int.tryParse('${data['baseSubjectId'] ?? ''}'),
questions: list,
raw: data,
);
}
}
+36 -22
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'media_pause_scope.dart';
/// ============================================================
/// 【AudioPlayerCard】通用音频播放控制条(在线音频)
@@ -43,7 +44,7 @@ import 'package:just_audio/just_audio.dart';
/// 不支持(返回 200 全量)→ ExoPlayer 无法字节定位,只能从头重新
/// 下载,表现就是"点中间段位置被重置回起点"(且第二次打开有系统
/// 缓存时会看似正常,极易误判)。验收新音频源:
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" <url> 看状态码。
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" `<url>` 看状态码。
/// ============================================================
class AudioPlayerCard extends StatefulWidget {
const AudioPlayerCard({
@@ -88,10 +89,14 @@ class AudioPlayerCard extends StatefulWidget {
}
class AudioPlayerCardState extends State<AudioPlayerCard>
with WidgetsBindingObserver {
with WidgetsBindingObserver
implements MediaPausable {
/// 底层播放器(just_audio)
final AudioPlayer _player = AudioPlayer();
/// 所属媒体暂停作用域(随挂载位置自动注册/注销)
MediaPauseRegistry? _pauseScope;
/// 是否正在加载(setUrl 未完成)
bool _loading = true;
@@ -115,15 +120,29 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
_initPlayer();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 自动挂到最近的 MediaPauseScope(含 fwfh 富文本内部的播放器)
final scope = MediaPauseScope.of(context);
if (scope != _pauseScope) {
_pauseScope?.unregister(this);
_pauseScope = scope;
_pauseScope?.register(this);
}
}
@override
void dispose() {
_pauseScope?.unregister(this);
WidgetsBinding.instance.removeObserver(this);
// 必须释放播放器,否则音频解码资源泄漏
_player.dispose();
super.dispose();
}
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停音频)
/// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
@override
void pause() {
_player.pause();
}
@@ -403,19 +422,11 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
);
}
/// 标题区:标题 + 地址/缓冲提示小字
/// 标题区:标题 + 缓冲提示小字(不展示音频源地址)
Widget _buildTitle({required bool buffering, required int percent}) {
// 小字:缓冲中显示百分比,否则显示源地址
final String subText;
if (buffering) {
subText = '缓冲中 $percent%...';
} else if (widget.asset != null) {
subText = widget.asset!;
} else {
subText = widget.url ?? '';
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title ?? (widget.asset != null ? '本地音频' : '音频'),
@@ -427,16 +438,19 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
color: Color(0xFF333333),
),
),
const SizedBox(height: 2),
Text(
subText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: buffering ? const Color(0xFFF59E0B) : const Color(0xFF999999),
// 仅缓冲中展示百分比小字,不展示播放链接 URL
if (buffering) ...[
const SizedBox(height: 2),
Text(
'缓冲中 $percent%...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: Color(0xFFF59E0B),
),
),
),
],
],
);
}
@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
/// 可被外部暂停的媒体(音频/视频播放器 State 实现)
abstract class MediaPausable {
void pause();
}
/// 媒体暂停注册表:播放器挂载时注册、卸载时注销,
/// 暂停时直接遍历注册表,不再运行时递归 visitChildElements,
/// 避免在 build/翻页动画/element 停用阶段遍历 element 树
/// 导致的生命周期断言(RenderViewport 收到 RenderErrorBox 等连锁报错)。
class MediaPauseRegistry {
final Set<MediaPausable> _items = <MediaPausable>{};
void register(MediaPausable item) => _items.add(item);
void unregister(MediaPausable item) => _items.remove(item);
/// 暂停注册表里的全部媒体。
/// 复制一份再遍历,防止 pause 触发的异步卸载修改集合。
void pauseAll() {
for (final item in _items.toList(growable: false)) {
item.pause();
}
}
}
/// 媒体暂停作用域:包裹需要统一暂停媒体的区域(如单个题目页/子题页)。
/// 子树中的 AudioPlayerCard/VideoPlayerView 会自动注册到最近的作用域。
class MediaPauseScope extends InheritedWidget {
final MediaPauseRegistry registry;
const MediaPauseScope({
super.key,
required this.registry,
required super.child,
});
/// 查找最近的作用域注册表;不存在返回 null(不建立依赖关系)。
static MediaPauseRegistry? of(BuildContext context) {
final widget = context
.getElementForInheritedWidgetOfExactType<MediaPauseScope>()
?.widget;
return widget is MediaPauseScope ? widget.registry : null;
}
@override
bool updateShouldNotify(MediaPauseScope oldWidget) =>
registry != oldWidget.registry;
}
+147 -12
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:new_learn/core/components/audio_player_card.dart';
import 'package:new_learn/core/components/video_player_view.dart';
@@ -20,6 +22,8 @@ import 'package:new_learn/core/components/video_player_view.dart';
/// customStylesBuilder ← 自定义标签样式(如把 .answer 高亮)
/// customWidgetBuilder ← 自定义元素替换(把任意标签换成业务 widget)
/// renderMode ← 渲染模式(默认 column;列表预览可用 listView)
/// debugImage ← 是否打印 img 解析日志
/// assumeRpx ← HTML 里的 width/height 是否按 rpx 处理
///
/// 三、内置扩展点(本组件已经帮你处理的):
/// 1. <audio> 标签 → 自动替换成 AudioPlayerCard(本项目封装的音频控制条)
@@ -27,7 +31,12 @@ import 'package:new_learn/core/components/video_player_view.dart';
/// 2. <video> 标签 → 自动替换成 VideoPlayerView(本项目内嵌播放器,
/// 无 Scaffold/AppBar),默认 autoPlay: false(避免富文本里
/// 多个视频同时解码占内存),取 title 属性作为错误提示;
/// 3. 公式(\(...\) LaTeX)→ 需要 flutter_math_fork,后续扩展。
/// 3. <img> 标签 → 统一接管:
/// - .svg 走 flutter_svg(带 SSAA 超采样,去锯齿);
/// - 位图走 Image.network;
/// - 公式图(有明确 width/height,或 parent 是行内标签)
/// 用 InlineCustomWidget,按原始尺寸渲染,不缩放;
/// - 块级大图宽度撑满父容器,高度按比例自适应。
///
/// 四、学生端场景:
/// - 字号调节:外部用 Slider 控制 textStyle.fontSize 即可(demo 页示范);
@@ -52,8 +61,15 @@ class RichTextContent extends StatelessWidget {
this.customStylesBuilder,
this.customWidgetBuilder,
this.renderMode = RenderMode.column,
this.htmlWidgetKey,
this.debugImage = false,
this.assumeRpx = true,
});
/// 透传给内部 HtmlWidget 的 key(外部用于调用
/// HtmlWidgetState.scrollToAnchor 滚动到 #anchor)
final Key? htmlWidgetKey;
/// HTML 字符串(后端返回)
final String html;
@@ -72,31 +88,124 @@ class RichTextContent extends StatelessWidget {
/// 渲染模式:column(默认,嵌在滚动容器里)/ listView(页面级)
final RenderMode renderMode;
/// 是否打印 img 解析日志(定位“图片特别大”时打开)
final bool debugImage;
/// HTML 里的 width/height 是否按 rpx 处理。
/// true:750 设计稿,除以 2 再 .w;
/// false:直接当 px,走 .w。
final bool assumeRpx;
/// 行内图片最大高度(逻辑像素)。
/// 没给 width/height 的图片按这个高度兜底,避免撑满整行。
static const double _inlineMaxHeight = 28.0;
@override
Widget build(BuildContext context) {
return HtmlWidget(
key: htmlWidgetKey,
html,
textStyle: textStyle,
onTapUrl: onTapUrl ?? _defaultOnTapUrl,
customStylesBuilder: customStylesBuilder,
customWidgetBuilder: _combinedCustomWidget,
customStylesBuilder: _combinedStylesBuilder,
customWidgetBuilder: _combinedCustomWidget(context),
renderMode: renderMode,
);
}
/// 合并默认样式 + 用户自定义样式
CustomStylesBuilder? get _combinedStylesBuilder {
return (element) {
final userResult = customStylesBuilder?.call(element);
if (element.localName == 'img') {
final base = <String, String>{
'max-width': '100%',
'height': 'auto',
};
if (userResult != null) base.addAll(userResult);
return base;
}
return userResult;
};
}
/// 合并默认替换 + 用户自定义替换
CustomWidgetBuilder get _combinedCustomWidget => (element) {
// 内置:<audio> → 本项目 AudioPlayerCard
CustomWidgetBuilder _combinedCustomWidget(BuildContext context) => (element) {
// ============================================================
// 1. 统一处理 <img>:SVG + 位图
// ============================================================
if (element.localName == 'img') {
final src = element.attributes['src'];
if (src == null || src.isEmpty) return null;
final isSvg = src.toLowerCase().split('?').first.endsWith('.svg');
// 解析 width / height(有就用,没有就 null)
double? parsePx(String? raw) {
if (raw == null) return null;
return double.tryParse(raw.replaceAll(RegExp(r'[^0-9.]'), ''));
}
double? width = parsePx(element.attributes['width']);
double? height = parsePx(element.attributes['height']);
final style = element.attributes['style'];
if (style != null) {
final wMatch = RegExp(r'width\s*:\s*([0-9.]+)px').firstMatch(style);
final hMatch = RegExp(r'height\s*:\s*([0-9.]+)px').firstMatch(style);
width = double.tryParse(wMatch?.group(1) ?? '') ?? width;
height = double.tryParse(hMatch?.group(1) ?? '') ?? height;
}
if (isSvg) {
return InlineCustomWidget(
alignment: PlaceholderAlignment.middle, // ← 关键
child: SvgPicture.network(
src,
width: width,
height: height,
fit: BoxFit.fill,
clipBehavior: Clip.antiAlias,
// SVG 解码/下载期间显示加载动效,否则默认空白
placeholderBuilder: (_) =>
_buildLoadingPlaceholder(width, height),
),
);
} else {
return InlineCustomWidget(
alignment: PlaceholderAlignment.middle, // ← 关键
child: Image.network(
src,
width: width,
height: height,
fit: BoxFit.fill,
// 网络图片加载期间显示进度小菊花,否则默认空白
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
final expected = loadingProgress.expectedTotalBytes;
final progress = expected == null
? null
: loadingProgress.cumulativeBytesLoaded / expected;
return _buildLoadingPlaceholder(width, height, progress);
},
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
);
}
}
// ============================================================
// 2. 内置:<audio> → 本项目 AudioPlayerCard
// ============================================================
if (element.localName == 'audio') {
final src = element.attributes['src'];
if (src != null && src.isNotEmpty) {
// 支持 asset:// 协议 → 本地音频(seek 秒响应)
// 注意:网络源必须支持 HTTP Range(响应 206),
// 否则 ExoPlayer 无法字节定位,seek 会失效"回到起始"。
// 免费测试站(如 samplelib)常返回 200 不支持 Range,请勿用。
final isAsset = src.startsWith('asset://');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: EdgeInsets.symmetric(vertical: 8.h),
child: AudioPlayerCard(
url: isAsset ? null : src,
asset: isAsset ? src.substring('asset://'.length) : null,
@@ -105,13 +214,16 @@ class RichTextContent extends StatelessWidget {
);
}
}
// 内置:<video> → 本项目 VideoPlayerView(内嵌播放器)
// 默认不自动播,避免富文本里多个视频同时解码占内存
// ============================================================
// 3. 内置:<video> → 本项目 VideoPlayerView(内嵌播放器)
// 默认不自动播,避免富文本里多个视频同时解码占内存
// ============================================================
if (element.localName == 'video') {
final src = element.attributes['src'];
if (src != null && src.isNotEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: EdgeInsets.symmetric(vertical: 8.h),
child: VideoPlayerView(
url: src,
title: element.attributes['title'] ?? '题目视频',
@@ -120,13 +232,36 @@ class RichTextContent extends StatelessWidget {
);
}
}
// 透传用户自定义替换(公式等扩展点)
// ============================================================
// 4. 透传用户自定义替换(公式等扩展点)
// ============================================================
return customWidgetBuilder?.call(element);
};
/// 图片/SVG 加载中的占位:居中的小菊花(有已知大小时按图片尺寸占位,
/// 行内无尺寸图给一个最小兜底盒子,避免占位为 0 看不到动效)
Widget _buildLoadingPlaceholder(double? width, double? height,
[double? progress]) {
return Container(
width: width ?? 40.w,
height: height ?? _inlineMaxHeight.h,
alignment: Alignment.center,
child: SizedBox(
width: 14.w,
height: 14.w,
child: CircularProgressIndicator(
strokeWidth: 1.5,
// 拿不到总字节数时传 null,小菊花保持 indeterminate 匀速旋转
value: progress,
),
),
);
}
/// 默认链接拦截:防止题目里夹带外部危险链接
static Future<bool> _defaultOnTapUrl(String url) async {
debugPrint('[RichTextContent] 已拦截外部链接: $url');
return true;
}
}
}
+335 -30
View File
@@ -1,8 +1,10 @@
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart' show compute, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:image/image.dart' as img;
import 'package:new_learn/core/api/public_upload/api.dart';
import 'package:new_learn/core/api/public_upload/model.dart';
import 'package:new_learn/core/components/center_toast.dart';
@@ -58,6 +60,9 @@ enum UploadMode { edit, view }
/// 上传组件类型
enum UploadType { handouts, questions, aiGrading, feedback }
/// 选图后的“原始图 + 压缩处理图”配对
typedef UploadPair = ({PickedImage origin, ProcessedImage processed});
/// 图片项(统一承载:本地已�?+ 已上传)
class UploadItem {
const UploadItem({
@@ -267,18 +272,28 @@ class UploadState extends State<Upload> {
processingLoadingShown = true;
// 压缩 + EXIF 修正 + 尺寸过滤
final List<({PickedImage origin, ProcessedImage processed})> validList =
[];
final List<UploadPair> validList = [];
for (final img in picked) {
final bytes = img.bytes ?? await img.entity?.originBytes;
if (bytes == null || bytes.isEmpty) continue;
final processed = await ImageProcessor.process(
bytes: bytes,
fileName: img.name,
maxDimension: 1920,
);
final ProcessedImage? processed;
if (kIsWeb) {
// Web:只有字节,走 bytes 压缩
final bytes = img.bytes;
if (bytes == null || bytes.isEmpty) continue;
processed = await ImageProcessor.process(
bytes: bytes,
fileName: img.name,
maxDimension: 1920,
);
} else {
// 原生:只传文件路径,原图字节不进 Dart 堆、UI 线程不解码
final path = img.path;
if (path == null || path.isEmpty) continue;
processed = await ImageProcessor.processFile(
path: path,
maxDimension: 1920,
);
}
if (processed == null) {
// 压缩失败 / 解码失败 �?跳过
continue;
@@ -301,9 +316,27 @@ class UploadState extends State<Upload> {
return;
}
// TODO: AIGrading 类型的旋转预览弹窗待实现(Vue 端有 $showImagePreview�?
// 暂时直接上传,后续有需求再�?
await _startUpload(validList);
// AIGrading(上传图片作答):先进入全屏旋转预览,
// 用户点“完成”才烘焙旋转并上传;点“取消”则整批放弃(对齐 Vue)
List<UploadPair> pairsToUpload = validList;
if (widget.type == UploadType.aiGrading) {
_hideLoading();
processingLoadingShown = false;
if (!mounted) return;
final rotated = await _ImageRotateEditor.open(
context,
items: validList,
);
if (rotated == null) return; // 用户取消
pairsToUpload = rotated;
if (!mounted) return;
_showLoading('上传中...');
processingLoadingShown = true;
}
await _startUpload(pairsToUpload);
} catch (e) {
_toast('图片处理失败,请重试');
debugPrint('[Upload] 选图异常: $e');
@@ -488,7 +521,7 @@ class UploadState extends State<Upload> {
@override
Widget build(BuildContext context) {
// 模式�?view 且没有图片时不显示(对齐 Vue v-if�?
// 模式:view 且没有图片时不显示(对齐 Vue v-if)
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
return _buildUploadBox();
}
@@ -520,31 +553,38 @@ class UploadState extends State<Upload> {
Widget _buildHeader() {
return Row(
children: [
Container(
padding: EdgeInsets.only(right: 14.w),
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(
Stack(
children: [
// 标题装饰底图:固定尺寸钉在右下角(对标 background-size: 119rpx auto)
Positioned(
right: 10.w,
bottom: 0,
child: Image.network(
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
width: 59.5.w,
height: 9.h,
fit: BoxFit.fill,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
alignment: Alignment.bottomRight,
repeat: ImageRepeat.noRepeat,
),
),
child: Text(
widget.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
Padding(
padding: EdgeInsets.only(right: 14.w),
child: Text(
widget.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
),
),
],
),
SizedBox(width: 14.w),
Text(
widget.mode == UploadMode.edit
? '已上传:${_imageList.length}/${widget.maxCount}张'
: '共 $_imageList.length 张',
: '共 ${_imageList.length} 张',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
),
],
@@ -672,3 +712,268 @@ class UploadState extends State<Upload> {
);
}
}
/// ============================================================
/// 全屏旋转预览(对标 Vue components/ImagePreviewPopup):
/// - 左右滑动逐张查看;
/// - “↻ 旋转90°”只改当前图片的视觉角度;
/// - “完成”才把角度烘焙进图片字节并返回(随后才上传);
/// - “取消”/系统返回 → 返回 null,整批放弃。
/// ============================================================
class _ImageRotateEditor extends StatefulWidget {
const _ImageRotateEditor({required this.items});
final List<UploadPair> items;
/// 打开旋转预览;完成返回旋转后的列表,取消返回 null
static Future<List<UploadPair>?> open(
BuildContext context, {
required List<UploadPair> items,
}) {
return Navigator.of(context).push<List<UploadPair>>(
PageRouteBuilder<List<UploadPair>>(
opaque: true,
transitionDuration: const Duration(milliseconds: 200),
reverseTransitionDuration: const Duration(milliseconds: 200),
pageBuilder: (_, __, ___) => _ImageRotateEditor(items: items),
transitionsBuilder: (_, animation, __, child) =>
FadeTransition(opacity: animation, child: child),
),
);
}
@override
State<_ImageRotateEditor> createState() => _ImageRotateEditorState();
}
class _ImageRotateEditorState extends State<_ImageRotateEditor> {
late final List<int> _angles = List.filled(widget.items.length, 0);
final PageController _pageController = PageController();
int _currentIndex = 0;
bool _busy = false;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onPageChanged(int index) => setState(() => _currentIndex = index);
void _handleRotate() {
if (_busy) return;
setState(() {
_angles[_currentIndex] = (_angles[_currentIndex] + 90) % 360;
});
}
Future<void> _handleConfirm() async {
if (_busy) return;
setState(() => _busy = true);
try {
final result = <UploadPair>[];
for (var i = 0; i < widget.items.length; i++) {
final pair = widget.items[i];
final angle = _angles[i] % 360;
if (angle == 0) {
result.add(pair);
} else {
result.add((
origin: pair.origin,
processed: await _bakeRotation(pair.processed, angle),
));
}
}
if (mounted) Navigator.of(context).pop(result);
} catch (_) {
if (mounted) {
setState(() => _busy = false);
CenterToast.show(context, '图片处理失败,请重试');
}
}
}
void _handleCancel() {
if (_busy) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PageView.builder(
controller: _pageController,
itemCount: widget.items.length,
onPageChanged: _onPageChanged,
itemBuilder: (context, index) {
return Center(
child: AnimatedRotation(
turns: _angles[index] / 360,
duration: const Duration(milliseconds: 200),
child: Image.memory(
widget.items[index].processed.bytes,
fit: BoxFit.contain,
),
),
);
},
),
// 指示点(多图时)
if (widget.items.length > 1)
Positioned(
left: 0,
right: 0,
bottom: 72.h,
child: _buildDots(),
),
// 底部操作栏
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildFooter(),
),
// “处理图片中...”遮罩
if (_busy)
const ColoredBox(
color: Colors.black38,
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
],
),
);
}
Widget _buildDots() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < widget.items.length; i++)
Container(
width: 7.w,
height: 7.w,
margin: EdgeInsets.symmetric(horizontal: 4.w),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _currentIndex
? Colors.white
: Colors.white.withValues(alpha: 0.3),
),
),
],
);
}
Widget _buildFooter() {
return SafeArea(
top: false,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
decoration: const BoxDecoration(color: Color(0xB3000000)),
child: Row(
children: [
_FooterTextButton(label: '取消', onTap: _handleCancel),
Expanded(
child: Center(
child: GestureDetector(
onTap: _handleRotate,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 8.h,
),
decoration: BoxDecoration(
color: const Color(0x26FFFFFF),
borderRadius: BorderRadius.circular(30.r),
),
child: Text(
'↻ 旋转90°',
style: TextStyle(
fontSize: 14.sp,
color: Colors.white,
),
),
),
),
),
),
_FooterTextButton(label: '完成', primary: true, onTap: _handleConfirm),
],
),
),
);
}
}
class _FooterTextButton extends StatelessWidget {
const _FooterTextButton({
required this.label,
required this.onTap,
this.primary = false,
});
final String label;
final VoidCallback onTap;
final bool primary;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
child: Text(
label,
style: TextStyle(
fontSize: 15.sp,
color: primary ? const Color(0xFF2979FF) : Colors.white,
fontWeight: primary ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
}
/// 旋转 isolate 任务
typedef _RotateTask = ({Uint8List bytes, int angle, bool isPng});
/// 把旋转角度烘焙进 [processed] 的字节(失败时保留原图)
Future<ProcessedImage> _bakeRotation(
ProcessedImage processed,
int angle,
) async {
final isPng = processed.contentType == 'image/png';
final rotated = await compute<_RotateTask, Uint8List?>(
_rotateInIsolate,
(bytes: processed.bytes, angle: angle, isPng: isPng),
);
if (rotated == null || rotated.isEmpty) return processed;
return ProcessedImage(
bytes: rotated,
extension: isPng ? 'png' : 'jpg',
contentType: isPng ? 'image/png' : 'image/jpeg',
originalBytes: processed.originalBytes,
processedBytes: rotated.length,
);
}
/// isolate 内:解码 → 顺时针旋转 → 编码(JPEG 质量 90,对齐 Vue canvas 0.9)
Uint8List? _rotateInIsolate(_RotateTask task) {
final src = img.decodeImage(task.bytes);
if (src == null) return null;
final dst = img.copyRotate(src, angle: task.angle);
final encoded =
task.isPng ? img.encodePng(dst) : img.encodeJpg(dst, quality: 90);
return Uint8List.fromList(encoded);
}
+21 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import 'media_pause_scope.dart';
/// ============================================================
/// 【VideoPlayerView】通用视频播放器(内嵌版,无 Scaffold/AppBar)
@@ -111,7 +112,8 @@ class VideoPlayerView extends StatefulWidget {
}
class VideoPlayerViewState extends State<VideoPlayerView>
with WidgetsBindingObserver {
with WidgetsBindingObserver
implements MediaPausable {
/// 官方底层控制器
VideoPlayerController? _videoController;
@@ -127,6 +129,9 @@ class VideoPlayerViewState extends State<VideoPlayerView>
/// 初始化成功后视频的真实宽高比(未成功前 null → 兜底 16:9)
double? _aspectRatio;
/// 所属媒体暂停作用域(随挂载位置自动注册/注销)
MediaPauseRegistry? _pauseScope;
@override
void initState() {
super.initState();
@@ -135,8 +140,21 @@ class VideoPlayerViewState extends State<VideoPlayerView>
_initPlayer();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 自动挂到最近的 MediaPauseScope(含 fwfh 富文本内部的播放器)
final scope = MediaPauseScope.of(context);
if (scope != _pauseScope) {
_pauseScope?.unregister(this);
_pauseScope = scope;
_pauseScope?.register(this);
}
}
@override
void dispose() {
_pauseScope?.unregister(this);
WidgetsBinding.instance.removeObserver(this);
// 释放顺序不能反:先 chewie 再 video_player(反了会闪退)
_chewieController?.dispose();
@@ -144,7 +162,8 @@ class VideoPlayerViewState extends State<VideoPlayerView>
super.dispose();
}
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停视频)
/// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
@override
void pause() {
_videoController?.pause();
}
+38 -1
View File
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:new_learn/core/logger/app_talker.dart';
@@ -53,6 +55,10 @@ class DioClient {
),
) {
if (enableNetworkLog) {
// 必须在 Talker 之前:把二进制 body 临时换成占位文本,
// 防止 Talker 把整张图(上百万字节)转成巨型日志字符串导致 OOM。
// 真实字节存在 extra 里,由 _buildInterceptors 的 onRequest 还原。
_dio.interceptors.add(_BinaryLogMaskInterceptor());
_dio.interceptors.add(
TalkerDioLogger(
talker: talker,
@@ -88,6 +94,11 @@ class DioClient {
return InterceptorsWrapper(
// ① 请求发出前:加 Token + 打印日志
onRequest: (options, handler) {
// 还原被 _BinaryLogMaskInterceptor 临时替换的二进制 body,保证实际上传内容不变
final realBody = options.extra.remove(_kRealBodyKey);
if (realBody != null) {
options.data = realBody;
}
// OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名),
// 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名
final hasAuth =
@@ -177,16 +188,20 @@ class DioClient {
ProgressCallback? onReceiveProgress,
int retries = 0,
Map<String, dynamic>? headers,
Duration? receiveTimeout,
}) async {
var attempt = 0;
while (true) {
try {
final options = (headers != null || receiveTimeout != null)
? Options(headers: headers, receiveTimeout: receiveTimeout)
: null;
final resp = await _dio.get<dynamic>(
path,
queryParameters: query,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
options: headers == null ? null : Options(headers: headers),
options: options,
);
return resp.data;
} on DioException catch (e) {
@@ -337,3 +352,25 @@ class ApiException implements Exception {
@override
String toString() => message;
}
/// extra 中暂存真实二进制 body 的 key
const String _kRealBodyKey = '_realBinaryBody@dio';
/// 二进制日志屏蔽拦截器(必须在 TalkerDioLogger 之前执行):
/// 把二进制 body 暂存到 extra 并替换成简短占位文本,
/// 避免 Talker 把整张图转成巨型日志字符串、堆积在日志历史里造成 OOM。
/// 真实字节由 _buildInterceptors 的 onRequest 还原,不影响实际请求内容。
class _BinaryLogMaskInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final data = options.data;
if (data is TypedData) {
options.extra[_kRealBodyKey] = data;
options.data = '<binary ${data.lengthInBytes} bytes>';
} else if (data is Stream<List<int>>) {
options.extra[_kRealBodyKey] = data;
options.data = '<binary stream>';
}
handler.next(options);
}
}
+23
View File
@@ -16,6 +16,9 @@ import 'package:new_learn/features/my/my_page.dart';
import 'package:new_learn/features/my/note/note_page.dart';
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
import 'package:new_learn/features/conquerPackage/beforeDuringClass/index.dart';
import 'package:new_learn/features/conquerPackage/classExercises/class_exercises_page.dart';
import 'package:new_learn/features/conquerPackage/report/report_page.dart';
import 'package:new_learn/features/conquerPackage/transitionPage/transition_page.dart';
import 'package:new_learn/features/my/my_point/my_point_page.dart';
import 'package:new_learn/features/my/question_list/question_list_page.dart';
import 'package:new_learn/features/my/my_report/my_report_page.dart';
@@ -217,6 +220,26 @@ final GoRouter appRouter = GoRouter(
extra: state.extra as Map<String, dynamic>?,
),
),
GoRoute(
path: '/conquerPackage/transitionPage',
builder: (_, state) => TransitionPage(
extra: state.extra as Map<String, dynamic>?,
),
),
// ---------- 攻克包 - 课后习题 ----------
GoRoute(
path: '/conquerPackage/classExercises',
builder: (_, state) => ClassExercisesPage(
extra: state.extra as Map<String, dynamic>?,
),
),
// ---------- 攻克包 - 学习报告(占位) ----------
GoRoute(
path: '/conquerPackage/report',
builder: (_, state) => ReportPage(
extra: state.extra as Map<String, dynamic>?,
),
),
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
// ---------- 背诵模块二级页(不在 ShellBranch 内,push 时不显示 tabbar) ----------
// 教材列表
+10
View File
@@ -0,0 +1,10 @@
// ============================================================
// 图片文件头信息(条件导出):
// native(dart.library.io 为真)→ image_file_header_io.dart
// web(dart.library.io 为假) → image_file_header_web.dart
//
// 仅用 RandomAccessFile 读 JPEG/PNG 文件头获取尺寸/格式,
// 不把整张图读进内存、不做像素解码。
// ============================================================
export 'image_file_header_web.dart'
if (dart.library.io) 'image_file_header_io.dart';
+118
View File
@@ -0,0 +1,118 @@
// 仅当 dart.library.io 为真(native)时编译。
import 'dart:io';
import 'dart:typed_data';
/// 图片文件头信息(仅读头部,不加载像素)
class ImageFileHeader {
const ImageFileHeader({
required this.width,
required this.height,
required this.length,
required this.isPng,
required this.isJpeg,
});
final int width;
final int height;
final int length;
final bool isPng;
final bool isJpeg;
}
/// 读取本地图片文件头:尺寸 / 格式 / 文件大小。
///
/// 使用 RandomAccessFile 按 marker 顺序扫描,每次仅读几个字节:
/// - PNG:IHDR 固定在偏移 16;
/// - JPEG:从 SOI 后逐段扫描,SOF 段内即宽高(在 SOS 之前)。
/// 不支持的格式 / 损坏文件返回 null。
Future<ImageFileHeader?> readImageFileHeader(String path) async {
final file = File(path);
final raf = await file.open();
try {
final sig = await raf.read(4);
if (sig.length < 4) return null;
// -------- PNG:89 50 4E 47 0D 0A 1A 0A,IHDR 在偏移 16 --------
if (sig[0] == 0x89 &&
sig[1] == 0x50 &&
sig[2] == 0x4E &&
sig[3] == 0x47) {
await raf.setPosition(16);
final d = await raf.read(8);
if (d.length < 8) return null;
final width = (d[0] << 24) | (d[1] << 16) | (d[2] << 8) | d[3];
final height = (d[4] << 24) | (d[5] << 16) | (d[6] << 8) | d[7];
return ImageFileHeader(
width: width,
height: height,
length: await raf.length(),
isPng: true,
isJpeg: false,
);
}
// -------- JPEG:FF D8 FF --------
if (sig[0] == 0xFF && sig[1] == 0xD8 && sig[2] == 0xFF) {
await raf.setPosition(2);
while (true) {
// 定位段 marker(跳过填充字节)
final prefix = await raf.read(1);
if (prefix.isEmpty || prefix[0] != 0xFF) return null;
int marker;
do {
final m = await raf.read(1);
if (m.isEmpty) return null;
marker = m[0];
} while (marker == 0xFF);
// 无长度段:SOI / TEM / RSTn
if (marker == 0xD8 || marker == 0x01) continue;
if (marker >= 0xD0 && marker <= 0xD9) {
if (marker == 0xD9) return null; // EOI:未找到 SOF
continue;
}
final lenBytes = await raf.read(2);
if (lenBytes.length < 2) return null;
final segLen = (lenBytes[0] << 8) | lenBytes[1];
if (segLen < 2) return null;
// SOF 段(排除 C4=DHT、C8=JPG、CC=DAC)
if (marker >= 0xC0 &&
marker <= 0xCF &&
marker != 0xC4 &&
marker != 0xC8 &&
marker != 0xCC) {
// precision(1) + height(2) + width(2)
final sof = await raf.read(5);
if (sof.length < 5) return null;
final height = (sof[1] << 8) | sof[2];
final width = (sof[3] << 8) | sof[4];
return ImageFileHeader(
width: width,
height: height,
length: await raf.length(),
isPng: false,
isJpeg: true,
);
}
// 其它段:跳过段体(长度含自身 2 字节)
await raf.setPosition(await raf.position() + segLen - 2);
}
}
return null;
} finally {
await raf.close();
}
}
/// 读取文件全部字节(仅用于桌面等不支持原生压缩时的回退路径)
Future<Uint8List?> readImageFileBytes(String path) async {
try {
return Uint8List.fromList(await File(path).readAsBytes());
} catch (_) {
return null;
}
}
@@ -0,0 +1,25 @@
// 仅当 dart.library.io 为假(Web)时编译。
// Web 端选图直接拿到字节,不需要读文件头。
import 'dart:typed_data';
class ImageFileHeader {
const ImageFileHeader({
required this.width,
required this.height,
required this.length,
required this.isPng,
required this.isJpeg,
});
final int width;
final int height;
final int length;
final bool isPng;
final bool isJpeg;
}
/// Web 端无文件系统,返回 null
Future<ImageFileHeader?> readImageFileHeader(String path) async => null;
/// Web 端无文件系统,返回 null
Future<Uint8List?> readImageFileBytes(String path) async => null;
+64
View File
@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'image_file_header.dart';
/// ============================================================
/// 【ImageProcessor】图片压缩 + 方向修正 + 尺寸过滤
///
@@ -87,6 +89,68 @@ class ImageProcessor {
processedBytes: result.processedBytes,
);
}
/// 处理本地图片【文件】(原生端推荐入口):
/// - 尺寸/格式只读文件头(RandomAccessFile 读几字节),
/// UI 线程不做完整解码;
/// - 压缩交给原生侧直接读文件,Dart 堆不持有原图;
/// - 原生压缩不可用(如桌面平台)时回退纯 Dart isolate。
static Future<ProcessedImage?> processFile({
required String path,
int maxDimension = 1920,
int quality = 85,
}) async {
final header = await readImageFileHeader(path);
if (header == null) return null;
if (!_isInAllowedSize(header.width, header.height)) return null;
final target = _targetSize(
width: header.width,
height: header.height,
maxDimension: maxDimension,
);
final compressed = await FlutterImageCompress.compressWithFile(
path,
minWidth: target.width,
minHeight: target.height,
quality: quality,
format: header.isPng ? CompressFormat.png : CompressFormat.jpeg,
autoCorrectionAngle: true,
);
if (compressed != null && compressed.isNotEmpty) {
final bytes = Uint8List.fromList(compressed);
return ProcessedImage(
bytes: bytes,
extension: header.isPng ? 'png' : 'jpg',
contentType: header.isPng ? 'image/png' : 'image/jpeg',
originalBytes: header.length,
processedBytes: bytes.length,
);
}
// 回退:读文件字节 → 纯 Dart isolate(桌面等场景)
final fileBytes = await readImageFileBytes(path);
if (fileBytes == null || fileBytes.isEmpty) return null;
final result = await compute(
_processInIsolate,
(
bytes: fileBytes,
fileName: null,
maxDimension: maxDimension,
quality: quality,
),
);
if (result == null) return null;
return ProcessedImage(
bytes: result.bytes,
extension: result.extension,
contentType: result.contentType,
originalBytes: result.originalBytes,
processedBytes: result.processedBytes,
);
}
}
typedef _ProcessTask = ({
+5 -3
View File
@@ -106,8 +106,10 @@ class PickerService {
return image == null ? null : [image];
}
// 相册权限:Android 13+ 用 photos (READ_MEDIA_IMAGES),旧版用 storage
final galleryPermission = await _resolveGalleryPermission();
if (!context.mounted) return null;
final ok = await _ensurePermission(
permission: await _resolveGalleryPermission(),
permission: galleryPermission,
title: '需要相册权限',
purpose: '从相册选取图片用于上传错题、作业图片及更换头像',
context: context,
@@ -190,10 +192,10 @@ class PickerService {
// 不再强制锁拍照方向。题目/解答/讲义拍摄经常需要横拍,
// 后续交给 ImageProcessor 的原生压缩链做 autoCorrectionAngle。
onEntitySaving: (dialogContext, viewType, file) async {
final xFile = XFile(file.path);
// 只持有文件路径,不 readAsBytes:原图字节不进 Dart 堆,
// 后续压缩由原生侧直接读文件(避免大图 OOM)。
picked = PickedImage(
path: file.path,
bytes: await xFile.readAsBytes(),
name: file.path.split('/').last,
);
if (dialogContext.mounted) {
@@ -0,0 +1,303 @@
/// ============================================================
/// 答题卡构建(1:1 移植 Vue utils/examAnswerCard.js)
/// ============================================================
library;
/// 子题带选项、需按“小题答题卡”处理的复合题型
const Set<String> kQuestionTypesWithOptionChildren = {
'yuedulijie',
'yueduxuanzeti',
'qixuanwu',
'wanxingtiankongxuanzeti',
'tingli',
};
/// 仅这些学科按正常逻辑判断 hasOptionChildrenQuestion,其余学科直接 true
const List<int> kSubjectIdsForNormalCheck = [2, 4, 5, 8, 9];
bool _truthy(dynamic v) {
if (v == null) return false;
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v.isNotEmpty && v != '0';
return true;
}
dynamic _or(dynamic a, dynamic b) => _truthy(a) ? a : b;
int _num(dynamic v, [int def = 0]) {
if (v is num) return v.toInt();
final n = int.tryParse('${v ?? ''}');
return n ?? def;
}
/// 答案是否有值('' / null / '[]' 视为未答)
bool hasValue(dynamic value) {
if (value == null) return false;
final str = '$value'.trim();
if (str.isEmpty || str == '[]') return false;
return true;
}
List<dynamic> _metaList(Map<String, dynamic>? question) {
if (question == null) return const [];
final v = question['bkTagMetaList'];
return v is List ? v : const [];
}
/// 填空题:每个空都作答才算已答;非填空题返回 null
bool? isFillBlankFullyAnswered(Map<String, dynamic>? question) {
final metas = _metaList(question);
if (metas.isEmpty) return null;
bool metaHasAnswer(dynamic rawMeta) {
final meta = rawMeta is Map ? rawMeta : const {};
final answer = meta['myAnswer'] ?? meta['userAnswer'];
if (answer is List) return answer.isNotEmpty;
return hasValue(answer);
}
return metas.every(metaHasAnswer);
}
/// 题目是否已答
bool isQuestionAnswered(Map<String, dynamic> question) {
final fillBlankAnswered = isFillBlankFullyAnswered(question);
final answered = (fillBlankAnswered == null || !fillBlankAnswered)
? hasValue(question['userAnswer'])
: fillBlankAnswered;
return answered;
}
/// 读取题目子项(subQuestions 优先,其次 childs)
List<dynamic> getQuestionChildren(Map<String, dynamic>? item) {
if (item == null) return const [];
if (item['subQuestions'] is List && (item['subQuestions'] as List).isNotEmpty) {
return item['subQuestions'] as List;
}
if (item['childs'] is List && (item['childs'] as List).isNotEmpty) {
return item['childs'] as List;
}
return const [];
}
/// 给题目列表补充 hasOptionChildrenQuestion(会就地改写 item 个别字段)
List<Map<String, dynamic>> appendExamQuestionChildOptionStatus(
List<dynamic>? list,
dynamic subjectId,
) {
return (list ?? const []).map((rawItem) {
final item = Map<String, dynamic>.from(rawItem as Map);
final childs = item['childs'] is List ? item['childs'] as List : const [];
// 学科不在白名单:默认按小题答题卡处理
if (!kSubjectIdsForNormalCheck.contains(_num(subjectId, -1))) {
var hasOptionFlag = childs.isNotEmpty;
// 输入型填空题(仅一空)合并到大题
if (item['questionTypes'] == 'tiankongti' && childs.length == 1) {
final firstChild = childs.first as Map;
item['answerStatus'] = _or(firstChild['answerStatus'], item['answerStatus']);
item['userAnswer'] = _or(firstChild['userAnswer'], item['userAnswer']);
hasOptionFlag = false;
}
// 解答题/问答题有子题:整题按一道题提交,图片作答
if ((item['questionTypes'] == 'jiedati' || item['questionTypes'] == 'wendati') &&
childs.isNotEmpty) {
hasOptionFlag = false;
item['iSFilllankItemUpload'] = 1;
}
return {
...item,
'hasOptionChildrenQuestion': hasOptionFlag,
};
}
final childList = getQuestionChildren(item);
final hasOptionChildren =
kQuestionTypesWithOptionChildren.contains(item['questionTypes']) &&
childList.isNotEmpty &&
childList.any((c) =>
c is Map && c['options'] is List && (c['options'] as List).isNotEmpty);
final isInputFillBlankQuestion = item['questionTypes'] == 'tiankongti' &&
_num(item['iSFilllankItemUpload']) == 0 &&
(item['childs'] is List && (item['childs'] as List).length > 1);
return {
...item,
'hasOptionChildrenQuestion': hasOptionChildren || isInputFillBlankQuestion,
};
}).toList();
}
Map<String, dynamic> _rawId(dynamic sub) => sub is Map && sub['raw'] is Map
? Map<String, dynamic>.from(sub['raw'] as Map)
: const {};
/// 将题目拍平成答题卡题号列表
List<Map<String, dynamic>> buildExamAnswerCardQuestions(
List<dynamic>? questionsList, {
bool isExamAnswerCard = true,
}) {
final list = <Map<String, dynamic>>[];
var displaySort = 1;
(questionsList ?? const []).asMap().forEach((index, rawItem) {
final item = Map<String, dynamic>.from(rawItem as Map);
final subQuestions =
item['subQuestions'] is List ? item['subQuestions'] as List : const [];
if (isExamAnswerCard != true) {
if (item['hasChild'] == true && subQuestions.isNotEmpty) {
subQuestions.asMap().forEach((subIndex, rawSub) {
final sub = Map<String, dynamic>.from(rawSub as Map);
final subRaw = _rawId(sub);
final question = {
...sub,
'questionId': subRaw['id'] ?? '${item['id'] ?? index}-$subIndex',
'parentIndex': index,
'subIndex': subIndex,
'sort': _num(sub['sort']),
'targetSort': _num(sub['sort']),
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': _or(sub['userAnswer'], item['userAnswer']),
'correctAnswer': _or(sub['answer'], subRaw['answer']) ?? '',
'answerStatus': _or(sub['answerStatus'], 0),
'bkTagMetaList': sub['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
});
return;
}
final question = {
...item,
'questionId': item['id'] ?? index,
'parentIndex': index,
'subIndex': -1,
'sort': _num(item['sort']),
'targetSort': _num(item['sort']),
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': item['userAnswer'] ?? '',
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
'answerStatus': item['answerStatus'] ?? 0,
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
return;
}
final children = getQuestionChildren(item);
final collapseAsSingleQuestion = item['hasOptionChildrenQuestion'] == false;
if (collapseAsSingleQuestion) {
final childs = item['childs'] is List ? item['childs'] as List : const [];
final firstChildSort = children.isNotEmpty
? _num((children.first as Map)['sort'])
: 0;
var targetSort = _num(item['sort']);
if (targetSort == 0) targetSort = firstChildSort;
if (item['questionTypes'] == 'tiankongti' && childs.length == 1) {
final firstChild = childs.first as Map;
firstChild['questionTypeName'] = item['questionTypeName'] ?? '题目';
item['userAnswer'] = _or(firstChild['userAnswer'], item['userAnswer']);
item['answerStatus'] = _or(firstChild['answerStatus'], item['answerStatus']);
}
final question = {
...item,
'questionId': item['id'] ?? index,
'parentIndex': index,
'subIndex': -1,
'sort': displaySort++,
'targetSort': targetSort,
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': item['userAnswer'] ?? '',
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
'answerStatus': item['answerStatus'] ?? 0,
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
return;
}
if (children.isNotEmpty) {
children.asMap().forEach((subIndex, rawSub) {
final sub = Map<String, dynamic>.from(rawSub as Map);
final subRaw = _rawId(sub);
final question = {
...sub,
'questionId': subRaw['id'] ?? sub['id'] ?? '${item['id'] ?? index}-$subIndex',
'parentIndex': index,
'subIndex': subIndex,
'sort': displaySort++,
'targetSort': _num(sub['sort']),
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': _or(sub['userAnswer'], item['userAnswer']),
'correctAnswer':
sub['correctAnswer'] ?? sub['answer'] ?? subRaw['answer'] ?? '',
'answerStatus': sub['answerStatus'] ?? 0,
'bkTagMetaList': sub['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
});
return;
}
final question = {
...item,
'questionId': item['id'] ?? index,
'parentIndex': index,
'subIndex': -1,
'sort': displaySort++,
'targetSort': _num(item['sort']),
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': item['userAnswer'] ?? '',
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
'answerStatus': item['answerStatus'] ?? 0,
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
});
final filtered = list.where((item) {
final sort = _num(item['sort']);
if (sort <= 0) return false;
if (isExamAnswerCard == true) return true;
return _num(item['targetSort']) > 0 || sort > 0;
}).toList();
filtered.sort((a, b) => _num(a['sort']).compareTo(_num(b['sort'])));
return filtered;
}
/// 按相邻 questionTypeName 分组,供答题卡渲染
List<Map<String, dynamic>> buildExamAnswerCard(
List<dynamic>? questionsList, {
bool isExamAnswerCard = true,
}) {
final sortedQuestions = buildExamAnswerCardQuestions(
questionsList,
isExamAnswerCard: isExamAnswerCard,
);
final result = <Map<String, dynamic>>[];
Map<String, dynamic>? currentGroup;
for (final question in sortedQuestions) {
final typeName = '${question['questionTypeName'] ?? '题目'}';
if (currentGroup == null || currentGroup['name'] != typeName) {
currentGroup = {
'name': typeName,
'questions': <Map<String, dynamic>>[],
};
result.add(currentGroup);
}
(currentGroup['questions'] as List).add({
...question,
'status': question['answerStatus'],
});
}
return result;
}
@@ -0,0 +1,150 @@
/// ============================================================
/// 题干媒体拆分(1:1 移植 Vue utils/extractMediaFromStem.js)
///
/// 从题干 HTML 中拆出内嵌 audio/video,避免富文本直接渲染媒体异常。
/// ============================================================
library;
class StemMediaBlock {
const StemMediaBlock({required this.type, this.content, this.audioUrl, this.videoUrl, this.videoCoverUrl});
/// html / audio / video
final String type;
final String? content;
final String? audioUrl;
final String? videoUrl;
final String? videoCoverUrl;
Map<String, dynamic> toJson() => {
'type': type,
if (content != null) 'content': content,
if (audioUrl != null) 'audioUrl': audioUrl,
if (videoUrl != null) 'videoUrl': videoUrl,
if (videoCoverUrl != null) 'videoCoverUrl': videoCoverUrl,
};
}
class StemMedia {
const StemMedia({
this.content = '',
this.audios = const [],
this.videos = const [],
this.blocks = const [],
});
final String content;
final List<Map<String, dynamic>> audios;
final List<Map<String, dynamic>> videos;
final List<StemMediaBlock> blocks;
}
String _normalizeMediaAttr(String? value) =>
(value ?? '').replaceAll(RegExp(r'''[`'"\s]'''), '').trim();
String? _firstNonEmpty(List<String?> groups) {
for (final g in groups) {
if (g != null && g.isNotEmpty) return g;
}
return null;
}
({String src, String poster}) _extractMediaAttrs(String tagHtml) {
final srcMatch = RegExp(
r'''\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
caseSensitive: false,
).firstMatch(tagHtml);
final posterMatch = RegExp(
r'''\bposter\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
caseSensitive: false,
).firstMatch(tagHtml);
final sourceMatch = RegExp(
r'''<source[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
caseSensitive: false,
).firstMatch(tagHtml);
final src = _normalizeMediaAttr(_firstNonEmpty([
sourceMatch?.group(1),
sourceMatch?.group(2),
sourceMatch?.group(3),
srcMatch?.group(1),
srcMatch?.group(2),
srcMatch?.group(3),
]));
final poster = _normalizeMediaAttr(_firstNonEmpty([
posterMatch?.group(1),
posterMatch?.group(2),
posterMatch?.group(3),
]));
return (src: src, poster: poster);
}
StemMedia extractMediaFromStem(String? html) {
if (html == null || html.isEmpty) {
return const StemMedia();
}
final rawHtml = html;
final mediaTagReg = RegExp(
r'<audio\b[\s\S]*?</audio>|<video\b[\s\S]*?</video>',
caseSensitive: false,
);
final audios = <Map<String, dynamic>>[];
final videos = <Map<String, dynamic>>[];
final blocks = <StemMediaBlock>[];
var lastIndex = 0;
void pushHtmlBlock(String content) {
if (content.trim().isEmpty) return;
blocks.add(StemMediaBlock(type: 'html', content: content));
}
for (final m in mediaTagReg.allMatches(rawHtml)) {
final tagHtml = m.group(0)!;
final offset = m.start;
pushHtmlBlock(rawHtml.substring(lastIndex, offset));
if (RegExp(r'^<audio\b', caseSensitive: false).hasMatch(tagHtml)) {
final attrs = _extractMediaAttrs(tagHtml);
if (attrs.src.isNotEmpty) {
final audioItem = {'audioUrl': attrs.src};
audios.add(audioItem);
blocks.add(StemMediaBlock(type: 'audio', audioUrl: attrs.src));
}
} else {
final attrs = _extractMediaAttrs(tagHtml);
if (attrs.src.isNotEmpty) {
final videoItem = {
'videoUrl': attrs.src,
'videoCoverUrl': attrs.poster,
};
videos.add(videoItem);
blocks.add(StemMediaBlock(
type: 'video',
videoUrl: attrs.src,
videoCoverUrl: attrs.poster,
));
}
}
lastIndex = offset + tagHtml.length;
}
pushHtmlBlock(rawHtml.substring(lastIndex));
var content = rawHtml
.replaceAll(RegExp(r'<audio\b[\s\S]*?</audio>', caseSensitive: false), '')
.replaceAll(RegExp(r'<video\b[\s\S]*?</video>', caseSensitive: false), '');
content = content
.replaceAll(
RegExp(
r'<(div|p)([^>]*)>\s*(?:<br\s*/?>|&nbsp;|\s)*</\1>',
caseSensitive: false,
),
'',
)
.trim();
return StemMedia(content: content, audios: audios, videos: videos, blocks: blocks);
}
+10
View File
@@ -0,0 +1,10 @@
/// ============================================================
/// 公式渲染(对标 Vue utils/formula.js 的 renderLatexInHtml)
///
/// 注意:Vue 端该函数第 960 行 `return content` 是空操作(公式已由
/// 后端转成 SVG,见 RichTextContent 内置 flutter_svg 渲染)。
/// Flutter 侧保持 identity,不引入任何 LaTeX 依赖。
/// ============================================================
library;
String renderLatexInHtml(String? content) => content ?? '';
+139
View File
@@ -0,0 +1,139 @@
/// ============================================================
/// JS 正则兼容层(JRegExp / JMatch)
///
/// Vue 端大量解析脚本依赖 JavaScript 正则语义:
/// - 带 g 标志时 exec() 从 lastIndex 开始,命中后把 lastIndex
/// 推进到本次匹配末尾;失配后 lastIndex 归 0;
/// - replace(regex, fn) 回调参数为 (match, p1..pn, offset, src);
/// - 默认 i(忽略大小写)+ s(dotAll,. 匹配换行)。
///
/// Dart 的 RegExp 语法/语义与 JS 基本一致(支持反向引用 \1、
/// 先行断言 (?=...),不支持后行断言),但没有 lastIndex 游标,
/// 这里做一层薄封装,供移植代码保持原有 while(exec) 写法。
/// ============================================================
library;
/// JS 风格单次匹配结果
class JMatch {
JMatch(this._m, this.source);
final RegExpMatch _m;
/// 原始字符串
final String source;
/// 匹配起始下标(JS match.index)
int get index => _m.start;
/// 匹配结束下标
int get end => _m.end;
/// 整个匹配文本(JS match[0])
String get group0 => _m.group(0)!;
/// 捕获组总数
int get groupCount => _m.groupCount;
/// 取捕获组(JS match[n]),无匹配返回 null
String? call(int n) => _m.group(n);
/// 取捕获组,空串也按空串返回;不存在才返回 null
String? groupOrNull(int n) =>
n <= _m.groupCount ? _m.group(n) : null;
RegExpMatch get raw => _m;
}
/// JS 风格正则(默认 global + ignoreCase + dotAll)
class JRegExp {
JRegExp(
String pattern, {
this.global = true,
bool caseInsensitive = true,
bool dotAll = true,
bool multiLine = false,
}) : _re = RegExp(
pattern,
caseSensitive: !caseInsensitive,
dotAll: dotAll,
multiLine: multiLine,
);
final RegExp _re;
final bool global;
/// JS regex.lastIndex
int lastIndex = 0;
/// JS regex.exec(string)
JMatch? exec(String input) {
if (lastIndex < 0) lastIndex = 0;
if (lastIndex > input.length) {
lastIndex = 0;
return null;
}
RegExpMatch? found;
for (final m in _re.allMatches(input, lastIndex)) {
found = m;
break;
}
if (found == null) {
lastIndex = 0;
return null;
}
final match = JMatch(found, input);
if (global) {
// 零宽匹配时 JS 会让 lastIndex 前进 1 位,避免死循环
lastIndex = found.end == found.start ? found.end + 1 : found.end;
}
return match;
}
/// 重置游标
void reset() => lastIndex = 0;
/// JS String.match(regex):非 global 返回首个匹配,global 返回全量
List<JMatch>? all(String input) {
if (!global) {
final m = _re.firstMatch(input);
return m == null ? null : [JMatch(m, input)];
}
return _re.allMatches(input).map((m) => JMatch(m, input)).toList();
}
}
/// 非全局取首个匹配(对标 string.match(/.../i))
JMatch? jsFirstMatch(String input, String pattern, {bool dotAll = true}) {
final m = RegExp(pattern, dotAll: dotAll).firstMatch(input);
return m == null ? null : JMatch(m, input);
}
/// 对标 JS parseInt(value, radix):允许前导空白、前缀数字,尾部垃圾忽略
int? jsParseInt(dynamic value, [int radix = 10]) {
if (value == null) return null;
var s = '$value'.trim();
if (s.isEmpty) return null;
if (radix == 10) {
final m = RegExp(r'^[+-]?\d+').firstMatch(s);
if (m == null) return null;
return int.tryParse(m.group(0)!);
}
return int.tryParse(s, radix: radix);
}
/// 对标 JS Number(value)(仅在需要时使用)
num? jsNumber(dynamic value) {
if (value == null) return 0;
if (value is num) return value;
final s = '$value'.trim();
if (s.isEmpty) return 0;
return num.tryParse(s);
}
/// 对标 JS String.prototype.replace(regex, '$1') 中最常用的形态:
/// 用捕获组 1 替换每处匹配
String replaceWithGroup1(String input, String pattern) {
return input.replaceAllMapped(RegExp(pattern, dotAll: true), (m) {
return m.group(1) ?? '';
});
}
@@ -0,0 +1,99 @@
/// ============================================================
/// 题型 / 学科常量(对标 Vue utils/constant.js)
/// ============================================================
library;
/// 根据 questionTypeId 和 isObjective 获取对应的题型代码
class QuestionTypeCodeMap {
QuestionTypeCodeMap._();
/// 客观题(isObjective=true 时查这张表)
static const Map<String, String> objective = {
'12352055339057157': 'danxuanti', // 单选题
'12352065806598149': 'duoxuanti', // 多选题
'12352066848751621': 'panduanti', // 判断题
'792156155195462': 'panduanti', // 判断题(临时匹配数据,待定)
'805449231827078': 'qixuanwu', // 七选五
'807952051372102': 'qixuanwu', // 七选五
'12357397088370693': 'wanxingtiankongxuanzeti', // 完形填空选择题
'12357391078981637': 'yueduxuanzeti', // 阅读选择题
'806897094647878': 'wendati', // 问答题
'12357387196366853': 'tingli', // 听力
'792171174588486': 'yuedulijie', // 阅读理解
'12352067804725253': 'tiankongti', // 填空题
'12352069399805957': 'jiedati', // 解答题
'12352070327861253': 'jisuanti', // 计算题
'12352071266336773': 'yingyongti', // 应用题
'12352072842215429': 'shiyanti', // 实验题
'12357399440457733': 'xiezuo', // 写作
'12361758319509574': 'zhengmingti', // 证明题
};
/// 主观题(isObjective=false 时查这张表)
static const Map<String, String> subjective = {
'12352055339057157': 'danxuanti', // 单选题
'12352065806598149': 'duoxuanti', // 多选题
'12352066848751621': 'panduanti', // 判断题
'792156155195462': 'panduanti', // 判断题(临时匹配数据,待定)
'792171174588486': 'yuedulijie', // 阅读理解
'12352067804725253': 'tiankongti', // 填空题
'12352069399805957': 'jiedati', // 解答题
'12352070327861253': 'jisuanti', // 计算题
'12352071266336773': 'yingyongti', // 应用题
'12352072842215429': 'shiyanti', // 实验题
'12357387196366853': 'tingli', // 听力
'12357391078981637': 'yueduxuanzeti', // 阅读选择题
'12357397088370693': 'wanxingtiankongxuanzeti', // 完形填空选择题
'12357399440457733': 'xiezuo', // 写作
'12361758319509574': 'zhengmingti', // 证明题
'805449231827078': 'qixuanwu', // 七选五
'807952051372102': 'qixuanwu', // 七选五
'806897094647878': 'wendati', // 问答题
};
/// 通过 Code 获取题型
static const Map<String, String> typeCode = {
'1': 'danxuanti', // 单选题
'2': 'duoxuanti', // 多选题
'3': 'panduanti', // 判断题
'4': 'tiankongti', // 填空题
'5': 'jiedati', // 解答题
'9': 'tingli', // 听力
'qixuanwu': 'qixuanwu', // 七选五
'wangxingtiankong': 'wanxingtiankongxuanzeti', // 完形填空选择题
'wendati': 'wendati', // 问答题
'yueduljie': 'yuedulijie', // 阅读理解
};
}
/// 学科作答方式(type:0=输入框,1=图片作答/手写作答)
class SubjectInfo {
const SubjectInfo(this.id, this.name, this.type);
final int id;
final String name;
final int type;
}
const List<SubjectInfo> kSubjectList = [
SubjectInfo(1, '语文', 0),
SubjectInfo(2, '数学', 1),
SubjectInfo(3, '英语', 0),
SubjectInfo(4, '物理', 1),
SubjectInfo(5, '化学', 1),
SubjectInfo(6, '生物学', 0),
SubjectInfo(7, '政治(道法)', 0),
SubjectInfo(8, '历史', 1),
SubjectInfo(9, '地理', 1),
SubjectInfo(100013, '艺起看世界 ', 0), // 只有客观题,没有主观题
];
/// Vue: getSubjectTypeById —— 找不到返回 ''(load 处 `|| 0` 兜底)
int? getSubjectTypeById(dynamic subjectId) {
final id = int.tryParse('$subjectId');
if (id == null || id == 0) return null;
for (final item in kSubjectList) {
if (item.id == id) return item.type;
}
return null;
}
@@ -0,0 +1,150 @@
/// ============================================================
/// 题目解析总入口(1:1 移植 Vue utils/questionParser.ts)
///
/// parseQuestionData:根据 hasChild/childs 分发给 Standard /
/// NonStandard 解析器,并支持把后端原始 childs 按索引合并回来。
/// ============================================================
library;
import 'js_regex.dart';
import 'question_parser_base.dart';
import 'question_parser_standard.dart';
import 'question_parser_non_standard.dart';
export 'question_parser_base.dart';
export 'question_parser_standard.dart' show parseStandardQuestion, analysisTextOf;
export 'question_parser_non_standard.dart';
/// 对标 Vue formatQuestionText:去掉空格与换行
String formatQuestionText(String? s) {
if (s == null) return '';
return s.replaceAll(RegExp(r'[ \r\n]'), '');
}
/// 去掉 <strong class="hxdb-placeholder">...</strong> 占位盒
String _stripHxdbPlaceholder(String? html) {
if (html == null || html.isEmpty) return '';
var result = html;
result = result.replaceAll(
RegExp(
r"""<([a-z0-9]+)\b[^>]*\bclass\s*=\s*(["'])[^"']*\bhxdb-placeholder\b[^"']*\2[^>]*/>""",
caseSensitive: false,
),
'',
);
final startTagRegex = JRegExp(
r"""<([a-z0-9]+)\b([^>]*)\bclass\s*=\s*(["'])([^"']*\bhxdb-placeholder\b[^"']*)\3([^>]*)>""",
);
while (true) {
final match = startTagRegex.exec(result);
if (match == null) break;
final fullBlock = findFullElement(result, match);
result = result.replaceFirst(fullBlock, '');
startTagRegex.reset();
}
return result;
}
/// 按索引把原始 childs 合并回解析后的列表({...parsed, ...original})
List<Map<String, dynamic>> _mergeItemListWithOriginalChilds(
List<dynamic>? parsedList,
List<dynamic> originalChilds,
) {
final list = parsedList ?? const [];
final maxLen = list.length > originalChilds.length
? list.length
: originalChilds.length;
if (maxLen == 0) return const [];
return List.generate(maxLen, (index) {
final parsedItem = index < list.length && list[index] is Map
? Map<String, dynamic>.from(list[index] as Map)
: null;
final original = index < originalChilds.length && originalChilds[index] is Map
? Map<String, dynamic>.from(originalChilds[index] as Map)
: null;
if (parsedItem != null && original != null) {
return {...parsedItem, ...original};
}
if (parsedItem != null) return parsedItem;
return {...?original};
});
}
Map<String, dynamic> _mergeParsedWithOriginalChilds(
Map<String, dynamic> parsed,
List<dynamic> originalChilds,
) {
final mergedSubQuestions =
_mergeItemListWithOriginalChilds(parsed['subQuestions'] as List?, originalChilds);
// JS: parsed.childs 非空时用 childs,否则回退 subQuestions
final parsedChildsSource = (parsed['childs'] is List &&
(parsed['childs'] as List).isNotEmpty)
? parsed['childs'] as List
: (parsed['subQuestions'] as List? ?? const []);
final mergedChilds =
_mergeItemListWithOriginalChilds(parsedChildsSource, originalChilds);
final mergedBkTagMetaList =
_mergeItemListWithOriginalChilds(parsed['bkTagMetaList'] as List?, originalChilds);
return {
...parsed,
'subQuestions': mergedSubQuestions,
'childs': mergedChilds,
'bkTagMetaList':
mergedBkTagMetaList.isNotEmpty ? mergedBkTagMetaList : parsed['bkTagMetaList'],
};
}
Map<String, dynamic> _emptyParsed() => {
'stem': '',
'options': const <Map<String, String>>[],
'isSelect': false,
'answer': '',
'analysis': '',
'questionTypes': '',
};
/// 解析单道后端题目
Map<String, dynamic> parseQuestionData(
Map<String, dynamic>? detail, {
bool mergeOriginalChilds = false,
}) {
// 兼容外层包了一层 questions
final questionData = (detail?['questions'] is Map
? detail!['questions'] as Map
: detail)
as Map<String, dynamic>?;
if (questionData == null) return _emptyParsed();
final rawStem = _stripHxdbPlaceholder('${questionData['stem'] ?? ''}');
final rawAnswer = '${questionData['answer'] ?? ''}';
final rawCorrectAnswer = '${questionData['correctAnswer'] ?? ''}';
final rawAnalysisValue = questionData['answerAnalysis'];
final rawAnalysis = rawAnalysisValue is Map
? '${rawAnalysisValue['analysis'] ?? ''}'
: '${rawAnalysisValue ?? ''}';
// 只有空标签的解析置空
final analysis = cleanRichText(rawAnalysis, '').isNotEmpty ? rawAnalysis : '';
final hasChildsArray =
questionData['childs'] is List && (questionData['childs'] as List).isNotEmpty;
final originalChilds =
hasChildsArray ? (questionData['childs'] as List) : const [];
final hasChilds = questionData['hasChild'] == true && hasChildsArray;
final parsed = hasChilds
? parseStandardQuestion(
questionData, rawStem, rawAnswer, rawCorrectAnswer, analysis)
: parseNonStandardQuestion(
questionData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
if (mergeOriginalChilds && originalChilds.isNotEmpty) {
return _mergeParsedWithOriginalChilds(parsed, originalChilds);
}
return parsed;
}
@@ -0,0 +1,913 @@
/// ============================================================
/// 题目解析基础工具(1:1 移植 Vue utils/questionParserBase.ts)
///
/// 包含:选项提取、平衡闭合标签查找、`<bk>` 标签解析、判断题处理、
/// 题型代码、排序、富文本清洗、数学填空圈号、复合题题干格式化等。
/// ============================================================
library;
import 'dart:convert';
import 'question_constant.dart';
import 'js_regex.dart';
/// 填空占位元数据(对标 Vue BkTagMeta)
typedef BkTagMeta = Map<String, dynamic>;
/// 统一的选项结构
Map<String, String> makeOption(String label, String content) =>
{'optionLabel': label, 'options': content};
// ------------------------------------------------------------
// 选项提取
// ------------------------------------------------------------
/// 从 HTML 字符串中提取选项(hxdb-option-table-td / hxdb-sub-option-table-td)
List<Map<String, String>> extractOptionsFromHtml(String html) {
final options = <Map<String, String>>[];
final tdRegex = JRegExp(
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-(?:sub-)?option-table-td[^"]*"[^>]*>([\s\S]*?)</\1>',
);
const labelSrc = r'^(\s|<[^>]+>)*([A-Z])[\.\、\:\s]\s*(&nbsp;)?';
var index = 0;
JMatch? tdMatch;
while ((tdMatch = tdRegex.exec(html)) != null) {
final tdContent = tdMatch!(2) ?? '';
final labelMatch = RegExp(labelSrc, caseSensitive: false).firstMatch(tdContent);
var optionLabel = labelMatch != null ? labelMatch.group(2)! : '';
if (optionLabel.isEmpty) {
optionLabel = String.fromCharCode(65 + index);
}
// 去掉选项字母与分隔符,保留前面的空白/标签结构
final cleanedContent = tdContent.replaceFirstMapped(
RegExp(labelSrc, caseSensitive: false),
(m) => m.group(1) ?? '',
);
options.add(makeOption(optionLabel, cleanedContent));
index++;
}
return options;
}
/// 寻找平衡的闭合标签(解决嵌套 div/table 匹配问题)
String findFullElement(String html, JMatch startTagMatch) {
final startIdx = startTagMatch.index;
final startTagFull = startTagMatch.group0;
final tagNameMatch = RegExp(r'^<([a-z0-9]+)', caseSensitive: false)
.firstMatch(startTagFull);
if (tagNameMatch == null) return startTagFull;
final tagName = tagNameMatch.group(1)!;
var depth = 1;
var currentPos = startIdx + startTagFull.length;
final tagRegex = JRegExp('<(\\/)?$tagName[^>]*>');
tagRegex.lastIndex = currentPos;
JMatch? tagMatch;
while ((tagMatch = tagRegex.exec(html)) != null) {
if (tagMatch!(1) == '/') {
depth--;
} else {
if (!tagMatch.group0.endsWith('/>')) {
depth++;
}
}
if (depth == 0) {
return html.substring(startIdx, tagMatch.end);
}
}
return startTagFull;
}
/// 剔除 stem 中的 hxdb-sub-option-table 选项表格
String stripOptionTable(String html) {
if (html.isEmpty) return '';
var result = html;
final tableRegex = JRegExp(
r'<table[^>]*class="[^"]*hxdb-sub-option-table[^"]*"[^>]*>',
);
while (true) {
final match = tableRegex.exec(result);
if (match == null) break;
final fullBlock = findFullElement(result, match);
result = result.replaceFirst(fullBlock, '');
tableRegex.reset();
}
return result;
}
/// 纯文本 A. xxx 选项兜底提取
List<Map<String, String>> extractPlainTextOptions(String html) {
final result = <Map<String, String>>[];
final plain = html
.replaceAll(RegExp(r'<[^>]+>'), '\n')
.replaceAll('&nbsp;', ' ')
.replaceAll(RegExp(r'\n{2,}'), '\n')
.trim();
final lines = plain.split('\n').where((l) => l.trim().isNotEmpty);
final optionRegex = RegExp(r'^([A-Z])[\.\、\s]\s*(.+)$');
for (final line in lines) {
final m = optionRegex.firstMatch(line.trim());
if (m != null) {
final label = m.group(1)!;
result.add(makeOption(
label,
'<span class="hxdb-option-label">$label.</span>&nbsp;${m.group(2)!.trim()}',
));
}
}
return result;
}
// ------------------------------------------------------------
// <bk> 填空标签解析
// ------------------------------------------------------------
final RegExp _attrIndexRe = RegExp(
r'''\bindex\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
caseSensitive: false,
);
final RegExp _attrTypeRe = RegExp(
r'''\btype\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
caseSensitive: false,
);
final RegExp _attrDataInputRe = RegExp(
r'''\bdata-input-type\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
caseSensitive: false,
);
String? _attrValue(RegExpMatch? m) =>
m == null ? null : (m.group(2) ?? m.group(3));
/// 解析填空题的 `<bk>` 标签,生成每个空的元数据
List<BkTagMeta> parseBkTagMetaFromStem(
String? stem,
String? correctAnswerHtml,
String? userAnswerJson, [
dynamic iSFilllankItemUpload,
List<Map<String, String>>? options,
]) {
if (stem == null || stem.isEmpty) return const [];
final correctAnswerMap = correctAnswerHtml != null && correctAnswerHtml.isNotEmpty
? parseCorrectAnswerMapFromHtml(correctAnswerHtml)
: <int, String>{};
final userAnswerMap = parseUserAnswerMapFromJson(
userAnswerJson,
jsNumber(iSFilllankItemUpload) == 1,
);
final list = <BkTagMeta>[];
final seenIndexes = <int>{};
final tagRegex = JRegExp(r'<bk\b([^>]*)>', caseInsensitive: true);
JMatch? m;
while ((m = tagRegex.exec(stem)) != null) {
final attrs = m!(1) ?? '';
final indexMatch = _attrIndexRe.firstMatch(attrs);
if (indexMatch == null) continue;
final index = jsNumber(_attrValue(indexMatch))?.toInt() ?? 0;
if (index <= 0) continue;
if (seenIndexes.contains(index)) continue;
seenIndexes.add(index);
final typeMatch = _attrTypeRe.firstMatch(attrs);
final dataInputTypeMatch = _attrDataInputRe.firstMatch(attrs);
final item = <String, dynamic>{
'index': index,
'type': _attrValue(typeMatch) ?? '',
'dataInputType': _attrValue(dataInputTypeMatch) ?? '',
'correctAnswer': correctAnswerMap[index] ?? '',
'options': options ?? const [],
};
final fromUser = userAnswerMap[index];
final isUpload = jsNumber(iSFilllankItemUpload) == 1;
final myAnswer = fromUser?['myAnswer'];
final hasImgArray = myAnswer is List &&
myAnswer.isNotEmpty &&
(myAnswer.first is Map) &&
(myAnswer.first as Map)['url'] != null;
if (isUpload || hasImgArray) {
final imgs = myAnswer is List ? myAnswer : const [];
if (imgs.isNotEmpty) item['myAnswer'] = imgs;
} else {
final my = myAnswer is String ? myAnswer.trim() : '';
if (my.isNotEmpty) item['myAnswer'] = my;
}
if (fromUser?['answerStatus'] is int) {
item['answerStatus'] = fromUser!['answerStatus'];
}
list.add(item);
}
return list;
}
String _normalizeAnswerUrl(dynamic u) =>
'$u'.replaceAll(RegExp(r'[`\s]'), '').trim();
/// 将用户作答 JSON 字符串解析为 {空索引: {myAnswer, answerStatus}}
Map<int, Map<String, dynamic>> parseUserAnswerMapFromJson([
String? userAnswerJson,
bool isUpload = false,
]) {
final map = <int, Map<String, dynamic>>{};
if (userAnswerJson == null || userAnswerJson.isEmpty) return map;
dynamic parsed;
try {
parsed = _jsonDecode(userAnswerJson);
} catch (_) {
return map;
}
if (parsed is! List) return map;
for (final rawItem in parsed) {
if (rawItem is! Map) continue;
final item = Map<String, dynamic>.from(rawItem);
final idx = jsNumber(item['index'] ?? item['Index'])?.toInt() ?? 0;
if (idx <= 0) continue;
final hasImg = item['Url'] != null ||
item['url'] != null ||
item['ThumbnailUrl'] != null ||
item['thumbnailUrl'] != null;
if (isUpload || hasImg) {
if (item.containsKey('myAnswer') || item.containsKey('answerStatus')) {
dynamic myAnswer = item['myAnswer'];
if (myAnswer is String) {
try {
final arr = _jsonDecode(myAnswer);
myAnswer = arr is List ? arr : <dynamic>[];
} catch (_) {
myAnswer = <dynamic>[];
}
}
if (myAnswer is! List) myAnswer = <dynamic>[];
final answerStatus = item['answerStatus'];
final payload = <String, dynamic>{};
if (myAnswer.isNotEmpty) payload['myAnswer'] = myAnswer;
if (answerStatus is int) payload['answerStatus'] = answerStatus;
final merged = Map<String, dynamic>.from(map[idx] ?? const {});
if (payload.containsKey('myAnswer')) merged['myAnswer'] = payload['myAnswer'];
if (payload.containsKey('answerStatus')) {
merged['answerStatus'] = payload['answerStatus'];
}
if (merged.containsKey('myAnswer') || merged.containsKey('answerStatus')) {
map[idx] = merged;
}
continue;
}
final url = _normalizeAnswerUrl(item['Url'] ?? item['url']);
if (url.isEmpty) continue;
final thumbnailUrl = _normalizeAnswerUrl(
item['ThumbnailUrl'] ?? item['thumbnailUrl'] ?? url,
);
final type = item['Type'] ?? item['type'] ?? 0;
final imgItem = <String, dynamic>{
...item,
'index': idx,
'type': type,
'url': url,
'thumbnailUrl': thumbnailUrl,
};
final existing = map[idx];
final list = existing?['myAnswer'] is List
? List<dynamic>.from(existing!['myAnswer'] as List)
: <dynamic>[];
list.add(imgItem);
map[idx] = {
...?existing,
'myAnswer': list,
};
} else {
final myAnswer = item['myAnswer'] is String ? item['myAnswer'] as String : '';
final answerStatus = item['answerStatus'];
final payload = <String, dynamic>{};
if (myAnswer.trim().isNotEmpty) payload['myAnswer'] = myAnswer;
if (answerStatus is int) payload['answerStatus'] = answerStatus;
if (payload.containsKey('myAnswer') || payload.containsKey('answerStatus')) {
map[idx] = payload;
}
}
}
return map;
}
/// 从标准答案 HTML 中按 data-index 切分 hxdb-subquestion 块
Map<int, String> parseCorrectAnswerMapFromHtml(String html) {
final result = <int, String>{};
if (html.isEmpty) return result;
final startTagRegex = JRegExp(
r'''<div\b([^>]*)\bclass\s*=\s*(["'])([^"']*\bhxdb-subquestion\b[^"']*)\2([^>]*)>''',
);
JMatch? match;
while ((match = startTagRegex.exec(html)) != null) {
final attrs = '${match!(1) ?? ''} ${match(3) ?? ''} ${match(4) ?? ''}';
final indexMatch = RegExp(
r'''\bdata-index\s*=\s*(["'])(\d+)\1''',
caseSensitive: false,
).firstMatch(attrs);
if (indexMatch == null) continue;
final idx = int.tryParse(indexMatch.group(2)!) ?? 0;
if (idx <= 0) continue;
final fullBlock = findFullElement(html, match);
result[idx] = fullBlock;
}
return result;
}
// ------------------------------------------------------------
// 题型 / 排序 / 答案清洗
// ------------------------------------------------------------
/// 判断题识别:命中则向 options 补“正确/错误”两项
bool processPanDuan(Map<String, dynamic>? detail, List<Map<String, String>> options) {
final answer = '${detail?['answer'] ?? ''}';
final typeId = '${detail?['questionTypeId'] ?? ''}';
final isPanduan = answer.contains('data-hxdb-type="panduanti"') ||
typeId == '12352066848751621' ||
typeId == '792156155195462';
if (isPanduan) {
if (options.isEmpty) {
options
..add(makeOption('正确', '正确'))
..add(makeOption('错误', '错误'));
}
return true;
}
return false;
}
/// 题型代码(对标 Vue getQuestionTypeCode)
String getQuestionTypeCode(dynamic typeId, dynamic isObjective, [dynamic typecodeKey]) {
final id = '$typeId';
if (typecodeKey != null &&
'$typecodeKey'.isNotEmpty &&
QuestionTypeCodeMap.typeCode.containsKey('$typecodeKey')) {
return QuestionTypeCodeMap.typeCode['$typecodeKey']!;
}
if (isObjective == true) {
return QuestionTypeCodeMap.objective[id] ?? '';
}
return QuestionTypeCodeMap.subjective[id] ?? '';
}
/// 给题目(含子题)追加从 1 开始的连续 sort
List<Map<String, dynamic>> appendQuestionSort(List<dynamic>? questions) {
if (questions == null) return const [];
var sort = 1;
return questions.map((rawItem) {
final item = rawItem is Map
? Map<String, dynamic>.from(rawItem)
: <String, dynamic>{};
final hasChild = item['hasChild'] == true;
final hasSubQuestions = item['subQuestions'] is List &&
(item['subQuestions'] as List).isNotEmpty;
if (hasChild && hasSubQuestions) {
final subQuestions = (item['subQuestions'] as List).map((rawSub) {
final sub = Map<String, dynamic>.from(rawSub as Map);
sub['sort'] = sort++;
return sub;
}).toList();
return {
...item,
'subQuestions': subQuestions,
};
}
return {
...item,
'sort': sort++,
};
}).toList();
}
/// 去 HTML 标签(对标 Vue stripAnswerPlain)
String stripAnswerPlain(dynamic html) {
if (html == null) return '';
return '$html'.replaceAll(RegExp(r'<[^>]+>'), '').trim();
}
// ------------------------------------------------------------
// 富文本清洗
// ------------------------------------------------------------
final RegExp _htmlAttrNameRe = RegExp(r'^[A-Za-z_:][A-Za-z0-9:._-]*$');
String _normalizeRichTextUrl(String value) =>
value.replaceAll(RegExp(r'[`\s]'), '').trim();
/// 统一处理 HTML 中图片尺寸样式(Flutter 为非 MP 端,尺寸输出 px)
String fixImageStyles(String? html) {
if (html == null || html.isEmpty) return '';
return html.replaceAllMapped(RegExp(r'<img\b([^>]*)>', caseSensitive: false),
(match) {
var newAttrs = match.group(1)!;
var extractedWidth = '';
var extractedHeight = '';
// 提取并移除原有 width/height 属性
newAttrs = newAttrs.replaceAllMapped(
RegExp(
r'''\b(width|height)\s*=\s*(?:(["'])(.*?)\2|([^\s>]+))''',
caseSensitive: false,
),
(m) {
final attrName = (m.group(1) ?? '').toLowerCase();
final val = m.group(3) ?? m.group(4) ?? '';
if (attrName == 'width') extractedWidth = val;
if (attrName == 'height') extractedHeight = val;
return '';
},
);
final styleObj = <String, String>{};
if (RegExp(r'\bstyle\s*=\s*', caseSensitive: false).hasMatch(newAttrs)) {
newAttrs = newAttrs.replaceAllMapped(
RegExp(r'''\bstyle\s*=\s*(["'])([\s\S]*?)\1''', caseSensitive: false),
(m) {
final styleValue = m.group(2) ?? '';
for (final item in styleValue.split(';')) {
final idx = item.indexOf(':');
if (idx > 0) {
final key = item.substring(0, idx).trim().toLowerCase();
final value = item.substring(idx + 1).trim();
if (key.isNotEmpty && value.isNotEmpty) styleObj[key] = value;
}
}
return '';
},
);
}
if (styleObj['width'] != null && extractedWidth.isEmpty) {
extractedWidth = styleObj['width']!;
}
if (styleObj['height'] != null && extractedHeight.isEmpty) {
extractedHeight = styleObj['height']!;
}
styleObj.remove('width');
styleObj.remove('height');
styleObj.remove('display');
// 非 MP-WEIXIN:纯数字尺寸输出 px
String processSize(String sizeVal) {
if (sizeVal.isEmpty) return '';
sizeVal = sizeVal.trim().toLowerCase();
if (RegExp(r'(ex|em|rem|%|vw|vh|rpx|auto)$').hasMatch(sizeVal)) {
return sizeVal;
}
final numMatch = RegExp(r'^([0-9.]+)').firstMatch(sizeVal);
if (numMatch != null) {
final num = double.tryParse(numMatch.group(1)!);
if (num != null) {
return '${num}px';
}
}
return sizeVal;
}
final finalWidth = processSize(extractedWidth);
final finalHeight = processSize(extractedHeight);
if (finalWidth.isNotEmpty) styleObj['width'] = finalWidth;
if (finalHeight.isNotEmpty) styleObj['height'] = finalHeight;
styleObj['max-width'] = '100% !important';
if (finalWidth.isEmpty && finalHeight.isEmpty) {
styleObj['display'] = 'block';
styleObj['margin'] = '10rpx auto';
} else {
styleObj['display'] = 'inline-block !important';
styleObj['vertical-align'] = 'middle !important';
styleObj['padding'] = '5px 0 !important';
styleObj['margin'] = '0 2px !important';
}
final styleStr = styleObj.entries
.map((e) => '${e.key}: ${e.value}')
.join('; ');
newAttrs += ' style="$styleStr"';
return '<img$newAttrs>';
});
}
String _sanitizeHtmlAttributeValue(String name, String value) {
var normalized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
if (RegExp(r'^(src|href|poster)$', caseSensitive: false).hasMatch(name)) {
normalized = _normalizeRichTextUrl(normalized);
} else if (name == 'style') {
normalized = normalized.replaceAll('"', '').replaceAll(RegExp(r'\s+'), ' ').trim();
}
return normalized.replaceAll('"', '&quot;');
}
String _sanitizeHtmlTag(String tag) {
if (tag.isEmpty || tag.startsWith('</') || tag.startsWith('<!')) {
return tag;
}
final tagMatch = RegExp(r'^<([A-Za-z0-9-]+)([\s\S]*?)(/?)>$').firstMatch(tag);
if (tagMatch == null) return tag;
final tagName = tagMatch.group(1)!;
final rawAttrs = tagMatch.group(2) ?? '';
final selfClosing = tagMatch.group(3) ?? '';
final attrs = <String>[];
final attrRegex = RegExp(
r'''([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?''',
);
for (final attrMatch in attrRegex.allMatches(rawAttrs)) {
final attrName = (attrMatch.group(1) ?? '').trim();
if (!_htmlAttrNameRe.hasMatch(attrName)) continue;
final hasValue = attrMatch.group(2) != null ||
attrMatch.group(3) != null ||
attrMatch.group(4) != null;
if (!hasValue) {
attrs.add(attrName);
continue;
}
final attrValue = attrMatch.group(2) ?? attrMatch.group(3) ?? attrMatch.group(4) ?? '';
attrs.add('$attrName="${_sanitizeHtmlAttributeValue(attrName, attrValue)}"');
}
return '<$tagName${attrs.isNotEmpty ? ' ${attrs.join(' ')}' : ''}${selfClosing.isNotEmpty ? ' /' : ''}>';
}
/// 修复编辑器产生的破碎 style 属性(font-family 被拆碎等)
String _repairMalformedTagAttributes(String html) {
if (html.isEmpty) return '';
return html.replaceAllMapped(RegExp(r'<[^>]+>'), (m) {
var tag = m.group(0)!;
if (tag.startsWith('</') || tag.startsWith('<!') ||
!RegExp(r'\bstyle\s*=').hasMatch(tag)) {
return tag;
}
final brokenFontFamily = RegExp(
r'''\sstyle=(["'])\s*font-family\s*:\s*\1''',
caseSensitive: false,
).hasMatch(tag);
if (!brokenFontFamily) return tag;
tag = tag.replaceAll(
RegExp(r'''\sstyle=(["'])\s*font-family\s*:\s*\1''', caseSensitive: false),
'',
);
tag = tag.replaceAll(
RegExp(r'''\s+[a-zA-Z][a-zA-Z0-9-]*=""(?=[\s/>])'''),
'',
);
tag = tag.replaceAll(RegExp(r'\s{2,}'), ' ');
return tag;
});
}
/// 将 <audio>/<video> 内 <source src> 提升到父标签
String _hoistMediaSourceToParent(String html) {
if (html.isEmpty) return '';
return html.replaceAllMapped(
RegExp(r'<(audio|video)\b([^>]*)>([\s\S]*?)</\1>', caseSensitive: false),
(m) {
final tagName = m.group(1)!;
final rawAttrs = m.group(2) ?? '';
final innerHtml = m.group(3) ?? '';
final directSrcMatch = RegExp(
r'''\bsrc\s*=\s*(["'])(.*?)\1''',
caseSensitive: false,
).firstMatch(rawAttrs);
final sourceSrcMatch = RegExp(
r'''<source\b[^>]*\bsrc\s*=\s*(["'])(.*?)\1''',
caseSensitive: false,
).firstMatch(innerHtml);
final finalSrc = _normalizeRichTextUrl(
(directSrcMatch?.group(2) ?? sourceSrcMatch?.group(2) ?? ''),
);
if (finalSrc.isEmpty) return m.group(0)!;
var nextAttrs = rawAttrs;
if (directSrcMatch != null) {
nextAttrs = nextAttrs.replaceFirst(directSrcMatch.group(0)!, 'src="$finalSrc"');
} else {
nextAttrs = '$nextAttrs src="$finalSrc"';
}
return '<$tagName$nextAttrs></$tagName>';
},
);
}
/// 富文本进入组件前的轻量清洗
String sanitizeRichTextHtml(String? content) {
if (content == null || content.isEmpty) return '';
var result = fixImageStyles(content);
result = _repairMalformedTagAttributes(result);
result = _hoistMediaSourceToParent(result);
result = result.replaceAllMapped(
RegExp(r'''\sstyle=(["'])([\s\S]*?)\1''', caseSensitive: false),
(m) {
final raw = m.group(2) ?? '';
if (RegExp(r'^\s*font-family\s*:\s*;?\s*$', caseSensitive: false).hasMatch(raw)) {
return '';
}
return m.group(0)!;
},
);
// 移除 KaTeX 隐藏 MathML
result = result.replaceAll(
RegExp(
r'''<span\b[^>]*class=(["'])[^"']*\bkatex-mathml\b[^"']*\1[^>]*>[\s\S]*?</span>''',
caseSensitive: false,
),
'',
);
result = result.replaceAllMapped(
RegExp(
r'''<(audio|video|img|source)\b[^>]*?(?:src|poster)\s*=\s*(["'])(.*?)\2''',
caseSensitive: false,
),
(m) {
final cleanedUrl = _normalizeRichTextUrl(m.group(3) ?? '');
return m.group(0)!.replaceFirst(m.group(3)!, cleanedUrl);
},
);
return result.replaceAllMapped(
RegExp(r'</?[A-Za-z][^>]*>'),
(m) => _sanitizeHtmlTag(m.group(0)!),
);
}
/// 富文本清洗 + 空内容兜底
String cleanRichText(String? content, [String defaultText = '']) {
if (content == null || content.isEmpty) return defaultText;
var result = fixImageStyles(content);
result = sanitizeRichTextHtml(result).replaceAll(
RegExp(r'<br>\s*<br>'),
'<br>',
);
result = result.replaceAllMapped(
RegExp(r'''src=(["'])(.*?)\1'''),
(m) => 'src=${m.group(1)}${(m.group(2) ?? '').replaceAll(RegExp(r'[`\s]'), '')}${m.group(1)}',
);
final plainText = result
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&nbsp;', '')
.trim();
final hasImgOrVideo = RegExp(
r'<(img|video|audio|iframe|embed|object|source)[^>]*>',
caseSensitive: false,
).hasMatch(result);
if (plainText.isEmpty && !hasImgOrVideo) return defaultText;
return result.isEmpty ? defaultText : result;
}
// ------------------------------------------------------------
// 数学填空圈号
// ------------------------------------------------------------
const List<String> _circledNumbers = [
'①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩',
'⑪','⑫','⑬','⑭','⑮','⑯','⑰','⑱','⑲','⑳',
'㉑','㉒','㉓','㉔','㉕','㉖','㉗','㉘','㉙','㉚',
'㉛','㉜','㉝','㉞','㉟','㊱','㊲','㊳','㊴','㊵',
'㊶','㊷','㊸','㊹','㊺','㊻','㊼','㊽','㊾','㊿',
];
/// 将题干数字占位符替换成圈号(数学学科)
String convertBlankToCircled(String? stem) {
if (stem == null || stem.isEmpty) return stem ?? '';
// 1. ___数字___ → ___①___(跳过 URL/文件名场景)
var result = stem.replaceAllMapped(
RegExp(r'(_+)(\d+)(_+)', caseSensitive: false),
(m) {
final prevChar = m.start > 0 ? stem[m.start - 1] : '';
final nextChar = m.end < stem.length ? stem[m.end] : '';
if (RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(prevChar) ||
RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(nextChar)) {
return m.group(0)!;
}
final num = int.tryParse(m.group(2)!) ?? 0;
if (num >= 1 && num <= 50) {
return '${m.group(1)}${_circledNumbers[num - 1]}${m.group(3)}';
}
return m.group(0)!;
},
);
// 2. text-decoration:underline 标签内纯数字 → 圈号
result = result.replaceAllMapped(
RegExp(
r'''(<[^>]*style=["'][^"']*text-decoration:\s*underline[^"']*["'][^>]*>)([\s\S]*?)(</[a-zA-Z][^>]*>)''',
caseSensitive: false,
),
(m) {
final openTag = m.group(1)!;
final content = m.group(2)!;
final closeTag = m.group(3)!;
final textOnly = content
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&nbsp;', ' ')
.trim();
if (RegExp(r'^\d+$').hasMatch(textOnly)) {
final num = int.tryParse(textOnly) ?? 0;
if (num >= 1 && num <= 50) {
final newOpenTag = openTag.replaceAll(
RegExp(r'text-decoration:\s*underline', caseSensitive: false),
'text-decoration:underline;text-underline-offset:3px',
);
return '$newOpenTag${content.replaceFirst(RegExp(r'\d+'), _circledNumbers[num - 1])}$closeTag';
}
}
return m.group(0)!;
},
);
return result;
}
// ------------------------------------------------------------
// 复合题题干:<bk> → 可点击 <a href="#subquestion-N">
// ------------------------------------------------------------
/// 格式化复合题题干(subQuestions 为解析后的子题 Map 列表)
String formatCompoundStem(
String? stem,
int activeSubIndex, {
bool disableHighlight = false,
List<Map<String, dynamic>> subQuestions = const [],
}) {
if (stem == null || stem.isEmpty) return '暂无题干';
var formattedStem = stem;
// 非自闭合 <bk ...>...</bk>
formattedStem = formattedStem.replaceAllMapped(
RegExp(r'<bk\b([^>]*)>([\s\S]*?)</bk>', caseSensitive: false),
(m) {
final attrs = m.group(1) ?? '';
final inner = m.group(2) ?? '';
final indexMatch = RegExp(
r'''(?:^|\s)index\s*=(['"]?)([^'"\s>]+)\1''',
caseSensitive: false,
).firstMatch(attrs);
if (indexMatch == null) return m.group(0)!;
final idx = (int.tryParse(indexMatch.group(2)!) ?? 1) - 1;
final hasNumber = RegExp(r'\d').hasMatch(inner);
final isOnlyNumber = RegExp(r'^\s*\d+\s*$').hasMatch(inner);
var content = inner;
if (!isOnlyNumber && !hasNumber) {
content = '___${idx + 1}___';
}
return '<a data-idx="$idx">$content</a>';
},
);
// 自闭合 <bk ... />
formattedStem = formattedStem.replaceAllMapped(
RegExp(r'<bk\b([^>]*?)\s*/\s*>', caseSensitive: false),
(m) {
final attrs = m.group(1) ?? '';
final indexMatch = RegExp(
r'''(?:^|\s)index\s*=(['"]?)([^'"\s>]+)\1''',
caseSensitive: false,
).firstMatch(attrs);
if (indexMatch == null) return m.group(0)!;
final idx = (int.tryParse(indexMatch.group(2)!) ?? 1) - 1;
return '<a data-idx="$idx">___${idx + 1}___</a>';
},
);
// 保护所有 HTML 标签,避免属性/标签内部文本被替换
final tagPlaceholders = <String>[];
var tagCounter = 0;
final protectedStem = formattedStem.replaceAllMapped(
RegExp(r'''<(?:[^>"']|"[^"]*"|'[^']*')*>'''),
(m) {
final token = '__TAG_${tagCounter++}__';
tagPlaceholders.add(m.group(0)!);
return token;
},
);
// 纯文本 ___数字___ → <a data-idx>
var processedStem = protectedStem.replaceAllMapped(
RegExp(r'(_+)(\d+)(_+)', caseSensitive: false),
(m) {
final src = m.input;
final prevChar = m.start > 0 ? src[m.start - 1] : '';
final nextChar = m.end < src.length ? src[m.end] : '';
if (RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(prevChar) ||
RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(nextChar)) {
return m.group(0)!;
}
final idx = (int.tryParse(m.group(2)!) ?? 1) - 1;
return '<a data-idx="$idx">${m.group(0)}</a>';
},
);
processedStem = processedStem.replaceAllMapped(
RegExp(r'__TAG_(\d+)__'),
(m) => tagPlaceholders[int.parse(m.group(1)!)],
);
formattedStem = processedStem;
// 注入样式/激活态/跳转链接
formattedStem = formattedStem.replaceAllMapped(
RegExp(r'<a data-idx="(\d+)">([\s\S]*?)</a>', caseSensitive: false),
(m) {
final idxStr = m.group(1)!;
final content = m.group(2) ?? '';
final textOnly = content
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&nbsp;', ' ')
.trim();
final hasImg = RegExp(r'<img', caseSensitive: false).hasMatch(content);
if (textOnly.isEmpty && !hasImg) return content;
final dataIdx = int.tryParse(idxStr) ?? -1;
var isActive = false;
var targetSubIndex = dataIdx;
if (subQuestions.isNotEmpty) {
if (dataIdx >= 0 && dataIdx < subQuestions.length) {
targetSubIndex = dataIdx;
isActive = dataIdx == activeSubIndex;
} else {
final numMatch = RegExp(r'\d+').firstMatch(textOnly);
var matchedIndex = -1;
if (numMatch != null) {
final textNum = int.parse(numMatch.group(0)!);
matchedIndex = subQuestions.indexWhere(
(q) => jsNumber(q['sort'])?.toInt() == textNum);
if (matchedIndex == -1) {
matchedIndex = subQuestions.indexWhere((q) {
final stemMatch = RegExp(
r'''<label[^>]*>.*?(\d+)\.\s*(?:&nbsp;)*</label>|<p>.*?(\d+)\.\s*(?:&nbsp;)*</p>''',
caseSensitive: false,
dotAll: true,
).firstMatch('${q['stem'] ?? ''}');
if (stemMatch != null) {
final n = int.tryParse(stemMatch.group(1) ?? stemMatch.group(2) ?? '');
return n == textNum;
}
return false;
});
}
}
if (matchedIndex != -1) {
targetSubIndex = matchedIndex;
isActive = matchedIndex == activeSubIndex;
} else {
targetSubIndex = -1;
isActive = false;
}
}
} else {
isActive = dataIdx == activeSubIndex;
}
const baseStyle =
'text-decoration: none; color: inherit; cursor: pointer; display: inline-block; line-height: 1; margin: 0 4px; white-space: nowrap; word-break: keep-all; vertical-align: middle;';
var activeStyle = 'border: 1px solid transparent; padding: 0 2px;';
if (isActive && !disableHighlight) {
final shouldHighlight = RegExp(r'\d').hasMatch(textOnly) ||
RegExp(r'_+').hasMatch(textOnly) ||
hasImg;
if (shouldHighlight) {
activeStyle =
'line-height:24rpx;height:24px;border: 1px solid rgba(61, 125, 245, 1); border-radius: 8px; background: rgba(41, 121, 255, 0.1); box-sizing: border-box; padding: 0 4px;';
}
}
return '<a href="#subquestion-$targetSubIndex" id="subquestion-$targetSubIndex" style="$baseStyle $activeStyle">$content</a>';
},
);
return formattedStem;
}
// ------------------------------------------------------------
// 内部工具
// ------------------------------------------------------------
dynamic _jsonDecode(String text) => jsonDecode(text);
@@ -0,0 +1,204 @@
/// ============================================================
/// 非标准题 / 单题解析器(1:1 移植 Vue utils/questionParserNonStandard.ts)
///
/// 处理不带 childs 的独立题目:单选 / 多选 / 判断 / 填空。
/// 七选五与多空填空题会在本地构造 childs 后转交 Standard 解析器。
/// ============================================================
library;
import 'question_parser_base.dart';
import 'question_parser_standard.dart';
import 'formula.dart';
Map<String, dynamic> parseNonStandardQuestion(
Map<String, dynamic> questionData,
String rawStemIn,
String rawAnswer,
String rawCorrectAnswer,
String analysis,
) {
var rawStem = rawStemIn;
final options = <Map<String, String>>[];
var isSelect = questionData['isSelect'] == true;
var isMainPanDuan = false;
// 1) 主判断题
if (processPanDuan(questionData, options)) {
isSelect = true;
isMainPanDuan = true;
}
// 2) 题干选项表
final optionTableRegex = RegExp(
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
caseSensitive: false,
);
final match = optionTableRegex.firstMatch(rawStem);
if (match != null) {
isSelect = true;
options.addAll(extractOptionsFromHtml(match.group(2) ?? ''));
rawStem = rawStem.replaceFirst(optionTableRegex, '');
}
var finalQuestionTypes = getQuestionTypeCode(
questionData['questionTypeId'],
questionData['isObjective'],
questionData['questionTypeCode'],
);
// 3) 纯文本选项兜底
if (options.isEmpty &&
(finalQuestionTypes == 'danxuanti' || finalQuestionTypes == 'duoxuanti')) {
final plainOptions = extractPlainTextOptions(rawStem);
if (plainOptions.isNotEmpty) {
options.addAll(plainOptions);
isSelect = true;
}
}
if (isMainPanDuan) {
finalQuestionTypes = 'panduanti';
}
// --- 七选五:按填空元数据构造 childs,转复合题 ---
if (finalQuestionTypes == 'qixuanwu') {
final metas = parseBkTagMetaFromStem(
rawStem,
rawCorrectAnswer,
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'],
options,
);
if (metas.isNotEmpty) {
final indexes = (questionData['indexes'] as List?) ?? const [];
final generatedChilds = List.generate(metas.length, (index) {
final meta = metas[index];
return <String, dynamic>{
...meta,
'id': '${questionData['id']}_${meta['index']}',
'stem': '',
'answer': meta['correctAnswer'],
'isObjective': true,
'questionTypes': 'qixuanwu_sub',
'options': options,
if (index < indexes.length && indexes[index] is Map)
...Map<String, dynamic>.from(indexes[index] as Map),
'sort': meta['index'],
};
});
final compositeData = <String, dynamic>{
...questionData,
'hasChild': true,
'isObjective': false,
'childs': generatedChilds,
'questionTypes': 'qixuanwu',
};
return parseStandardQuestion(
compositeData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
}
}
// --- 填空题:构造 childs 转复合题 ---
if (finalQuestionTypes == 'tiankongti') {
var metasList = parseBkTagMetaFromStem(
rawStem,
rawCorrectAnswer,
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'],
);
if (metasList.isEmpty) {
metasList = [
{
'index': 1,
'type': 0,
'dataInputType': 0,
'correctAnswer': rawCorrectAnswer,
'questionTypes': 'tiankongti',
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
}
];
final userAnswerMap = parseUserAnswerMapFromJson(
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'] == 1,
);
final fromUser = userAnswerMap[1];
if (fromUser != null) {
final isUpload = questionData['iSFilllankItemUpload'] == 1;
final myAnswer = fromUser['myAnswer'];
final hasImgArray = myAnswer is List &&
myAnswer.isNotEmpty &&
myAnswer.first is Map &&
(myAnswer.first as Map)['url'] != null;
if (isUpload || hasImgArray) {
metasList[0]['myAnswer'] = myAnswer is List ? myAnswer : const [];
} else {
metasList[0]['myAnswer'] = myAnswer is String ? myAnswer.trim() : '';
}
if (fromUser['answerStatus'] is int) {
metasList[0]['answerStatus'] = fromUser['answerStatus'];
}
}
}
if (metasList.isNotEmpty) {
final indexes = (questionData['indexes'] as List?) ?? const [];
final generatedChilds = List.generate(metasList.length, (index) {
final meta = metasList[index];
return <String, dynamic>{
...meta,
'id': '${questionData['id']}_${meta['index']}',
'questionId': questionData['id'],
'stem': '',
'answer': meta['correctAnswer'],
'isObjective': false,
'questionTypes': 'tiankongti',
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
if (index < indexes.length && indexes[index] is Map)
...Map<String, dynamic>.from(indexes[index] as Map),
'sort': meta['index'],
};
});
final compositeData = <String, dynamic>{
...questionData,
'hasChild': true,
'isObjective': false,
'childs': generatedChilds,
'questionTypes': 'tiankongti',
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
};
return parseStandardQuestion(
compositeData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
}
}
final optionSource = options.isNotEmpty
? options
: ((questionData['options'] as List?)?.whereType<Map>().toList() ?? const []);
return {
...questionData,
'raw': questionData,
'stem': fixImageStyles(renderLatexInHtml(rawStem)),
'options': optionSource.map((rawOpt) {
final opt = Map<String, dynamic>.from(rawOpt);
return {
...opt,
'options': fixImageStyles(renderLatexInHtml('${opt['options'] ?? ''}')),
};
}).toList(),
'isSelect': isSelect,
'questionTypes': finalQuestionTypes,
'answer': rawAnswer,
'analysis': fixImageStyles(renderLatexInHtml(analysis)),
'subQuestions': const [],
if (finalQuestionTypes == 'tiankongti')
'bkTagMetaList': [
{
'index': 1,
'type': 0,
'dataInputType': 0,
'correctAnswer': fixImageStyles(renderLatexInHtml(rawCorrectAnswer)),
}
],
};
}
@@ -0,0 +1,337 @@
/// ============================================================
/// 标准复合题解析器(1:1 移植 Vue utils/questionParserStandard.ts)
///
/// 处理带 childs 的复合题(阅读理解、完形填空、听力等):
/// 提取子题题干 / 选项 / 题型 / 答案,清洗大题干。
/// ============================================================
library;
import 'question_parser_base.dart';
import 'formula.dart';
import 'js_regex.dart';
bool _truthy(dynamic v) {
if (v == null) return false;
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v.isNotEmpty && v != '0';
return true;
}
/// answerAnalysis 字段可能是字符串,也可能是 {analysis: '...'}
String analysisTextOf(dynamic v) {
if (v == null) return '';
if (v is Map) return '${v['analysis'] ?? ''}';
return '$v';
}
String _latex(dynamic v) => renderLatexInHtml(v == null ? '' : '$v');
Map<String, dynamic> _renderBk(Map<String, dynamic> bk) {
return {
...bk,
'correctAnswer': fixImageStyles(_latex(bk['correctAnswer'])),
};
}
Map<String, dynamic> parseStandardQuestion(
Map<String, dynamic> questionData,
String rawStem,
String rawAnswer,
String rawCorrectAnswer,
String analysis,
) {
final options = <Map<String, String>>[];
var isSelect = _truthy(questionData['isSelect']);
final subQuestions = <Map<String, dynamic>>[];
var newStem = rawStem;
final childsRaw = (questionData['childs'] as List?) ?? const [];
for (var index = 0; index < childsRaw.length; index++) {
final child = Map<String, dynamic>.from(childsRaw[index] as Map);
final subOptions = <Map<String, String>>[];
var childStem = _latex(child['stem']);
var childAnswer = '${child['answer'] ?? ''}';
var childRawAnalysis = _latex(
child['answerAnalysis'] is Map
? (child['answerAnalysis'] as Map)['analysis']
: (child['answerAnalysis'] ?? child['analysis']),
);
// 子题无独立答案时,从大题干答案 rawAnswer 中按 data-index 提取
if (childAnswer.isEmpty && rawAnswer.isNotEmpty) {
final subAnswerRegex = JRegExp(
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*hxdb-subquestion[^"]*")'
'(?=[^>]*data-index="${index + 1}")[^>]*>',
);
final match = subAnswerRegex.exec(rawAnswer);
if (match != null) {
childAnswer = findFullElement(rawAnswer, match);
}
}
final childAnalysis = childRawAnalysis;
// 1) 从大题干中提取属于该子题的内容 / 选项表格
final subquestionRegex = JRegExp(
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*(?:hxdb-subquestion|hxdb-option-table-wrapper)[^"]*")'
'(?=[^>]*data-index="${index + 1}")[^>]*>',
);
JMatch? subMatch;
while ((subMatch = subquestionRegex.exec(newStem)) != null) {
final fullBlock = findFullElement(newStem, subMatch!);
final startTag = subMatch.group0;
final tagNameLen = (subMatch(1) ?? '').length;
final divContent = fullBlock.substring(
startTag.length,
fullBlock.length - (tagNameLen + 3),
);
if (childStem.isEmpty || childStem == '<p></p>') {
childStem = divContent;
}
subOptions.addAll(extractOptionsFromHtml(divContent));
newStem = newStem.replaceFirst(fullBlock, '');
subquestionRegex.reset();
}
// 2) 从子题自身 stem 提取选项
final optionTableRegex = RegExp(
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-(?:sub-)?option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
caseSensitive: false,
);
final childMatch = optionTableRegex.firstMatch(childStem);
if (childMatch != null) {
if (subOptions.isEmpty) {
subOptions.addAll(extractOptionsFromHtml(childMatch.group(2) ?? ''));
}
childStem = childStem.replaceFirst(optionTableRegex, '');
}
if (subOptions.isEmpty &&
RegExp(r'hxdb-sub-option-table', caseSensitive: false).hasMatch(childStem)) {
subOptions.addAll(extractOptionsFromHtml(childStem));
}
// 按 optionLabel 去重
final seenLabels = <String>{};
final deduped = subOptions.where((opt) {
if (opt['optionLabel']!.isEmpty) return true;
if (seenLabels.contains(opt['optionLabel'])) return false;
seenLabels.add(opt['optionLabel']!);
return true;
}).toList();
subOptions
..clear()
..addAll(deduped);
// 3) 判断题
final childIsPanDuan = processPanDuan(
{...child, 'answer': childAnswer},
subOptions,
);
child['questionTypeId'] =
(_truthy(child['questionTypeId']) && child['questionTypeId'] != 0)
? child['questionTypeId']
: questionData['questionTypeId'];
var childQuestionTypes = getQuestionTypeCode(
child['questionTypeId'],
child['isObjective'],
child['questionTypeCode'],
);
if (childIsPanDuan) {
childQuestionTypes = 'panduanti';
}
// 4) 组装子题(注意:...child 覆盖前面的显式字段,与 JS 展开顺序一致)
final computedOptions = subOptions.isNotEmpty
? subOptions
.map((opt) => makeOption(opt['optionLabel']!, _latex(opt['options'])))
.toList()
: (child['options'] is List
? (child['options'] as List)
.whereType<Map>()
.map((opt) => makeOption(
'${opt['optionLabel'] ?? ''}',
_latex(opt['options']),
))
.toList()
: <Map<String, String>>[]);
final subQuestionObj = <String, dynamic>{
'index': '${index + 1}.',
'stem': childStem,
'answer': childAnswer,
'analysis': childAnalysis,
'options': computedOptions,
'isSelect': childIsPanDuan || subOptions.isNotEmpty || _truthy(child['isSelect']),
};
subQuestionObj.addAll(child); // ...child
subQuestionObj['questionTypeId'] =
(_truthy(child['questionTypeId']) && child['questionTypeId'] != 0)
? child['questionTypeId']
: questionData['questionTypeId'];
subQuestionObj['questionTypeName'] = _truthy(child['questionTypeName'])
? child['questionTypeName']
: questionData['questionTypeName'];
subQuestionObj['questionTypes'] = childQuestionTypes;
subQuestionObj['raw'] = child;
if (childQuestionTypes == 'tiankongti') {
final childBkTagMetaList = parseBkTagMetaFromStem(
childStem,
'${child['correctAnswer'] ?? rawCorrectAnswer}',
child['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'],
);
if (childBkTagMetaList.isNotEmpty) {
subQuestionObj['bkTagMetaList'] = childBkTagMetaList;
}
}
subQuestions.add(subQuestionObj);
}
// 清洗主干残留的子题块
final cleanupRegex = JRegExp(
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*(?:hxdb-subquestion|hxdb-option-table-wrapper)[^"]*")[^>]*>',
);
while (true) {
final cleanupMatch = cleanupRegex.exec(newStem);
if (cleanupMatch == null) break;
final fullBlock = findFullElement(newStem, cleanupMatch);
newStem = newStem.replaceFirst(fullBlock, '');
cleanupRegex.reset();
}
var finalStem = newStem;
// 大题干自身的选项表(普通单选/多选)
final optionTableRegex = RegExp(
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
caseSensitive: false,
);
final match = optionTableRegex.firstMatch(finalStem);
if (match != null) {
isSelect = true;
options.addAll(extractOptionsFromHtml(match.group(2) ?? ''));
finalStem = finalStem.replaceFirst(optionTableRegex, '');
}
var finalQuestionTypes = getQuestionTypeCode(
questionData['questionTypeId'],
questionData['isObjective'],
questionData['questionTypeCode'],
);
if (options.isEmpty &&
(finalQuestionTypes == 'danxuanti' || finalQuestionTypes == 'duoxuanti')) {
final plainOptions = extractPlainTextOptions(finalStem);
if (plainOptions.isNotEmpty) {
options.addAll(plainOptions);
isSelect = true;
}
}
var bkTagMetaList = <Map<String, dynamic>>[];
if (finalQuestionTypes == 'tiankongti') {
bkTagMetaList = parseBkTagMetaFromStem(
finalStem,
rawCorrectAnswer,
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'],
);
if (bkTagMetaList.isEmpty) {
bkTagMetaList = [
{'index': 1, 'type': 0, 'dataInputType': 0, 'correctAnswer': rawCorrectAnswer}
];
final userAnswerMap = parseUserAnswerMapFromJson(
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'] == 1,
);
final fromUser = userAnswerMap[1];
if (fromUser != null) {
final isUpload = questionData['iSFilllankItemUpload'] == 1;
final myAnswer = fromUser['myAnswer'];
final hasImgArray = myAnswer is List &&
myAnswer.isNotEmpty &&
myAnswer.first is Map &&
(myAnswer.first as Map)['url'] != null;
if (isUpload || hasImgArray) {
if (myAnswer is List && myAnswer.isNotEmpty) {
bkTagMetaList[0]['myAnswer'] = myAnswer;
}
} else {
final my = myAnswer is String ? myAnswer.trim() : '';
if (my.isNotEmpty) bkTagMetaList[0]['myAnswer'] = my;
}
if (fromUser['answerStatus'] is int) {
bkTagMetaList[0]['answerStatus'] = fromUser['answerStatus'];
}
}
}
}
final renderedStem = fixImageStyles(_latex(finalStem));
final renderedAnalysis = fixImageStyles(_latex(analysis));
final optionSource = options.isNotEmpty
? options
: ((questionData['options'] as List?)?.whereType<Map>().toList() ?? const []);
final renderedOptions = optionSource.map((rawOpt) {
final opt = Map<String, dynamic>.from(rawOpt);
return {
...opt,
'options': fixImageStyles(_latex(opt['options'])),
};
}).toList();
final renderedSubQuestions = subQuestions.map((sub) {
final rendered = <String, dynamic>{...sub};
rendered['stem'] = fixImageStyles(_latex(sub['stem']));
rendered['analysis'] = fixImageStyles(_latex(sub['analysis']));
rendered['answerAnalysis'] = fixImageStyles(_latex(sub['answerAnalysis']));
final subOpts = (sub['options'] as List?) ?? const [];
rendered['options'] = subOpts.whereType<Map>().map((rawOpt) {
final opt = Map<String, dynamic>.from(rawOpt);
return {
...opt,
'options': fixImageStyles(_latex(opt['options'])),
};
}).toList();
final bks = (sub['bkTagMetaList'] as List?) ?? const [];
rendered['bkTagMetaList'] =
bks.whereType<Map>().map((bk) => _renderBk(Map<String, dynamic>.from(bk))).toList();
return rendered;
}).toList();
final renderedBkTagMetaList = bkTagMetaList.map(_renderBk).toList();
return {
...questionData,
'raw': questionData,
'stem': renderedStem,
'options': renderedOptions,
'isSelect': isSelect,
'questionTypes': finalQuestionTypes,
'childs': renderedSubQuestions,
'answer': rawAnswer,
'analysis': renderedAnalysis,
'subQuestions': renderedSubQuestions,
'bkTagMetaList': renderedBkTagMetaList.isNotEmpty
? renderedBkTagMetaList
: (finalQuestionTypes == 'tiankongti'
? [
{
'index': 1,
'type': 0,
'dataInputType': 0,
'correctAnswer': fixImageStyles(_latex(rawCorrectAnswer)),
}
]
: const []),
};
}
+92
View File
@@ -0,0 +1,92 @@
/// ============================================================
/// 题型标签渐变色(对标 Vue utils/tagQuestionTypeColor.js)
/// getTagColor(tag) → 题型名对应渐变;找不到=默认单选题蓝
/// ============================================================
library;
import 'package:flutter/material.dart';
/// 题型渐变描述(起止色 + 角度近似:281.96deg≈右上→左下,126.17deg≈左上→右下)
class TagGradient {
const TagGradient(this.begin, this.end, this.colors);
final Alignment begin;
final Alignment end;
final List<Color> colors;
Gradient get gradient => LinearGradient(
begin: begin,
end: end,
colors: colors,
);
}
const TagGradient _defaultTagGradient = TagGradient(
Alignment(-0.95, -0.2), // 281.96deg
Alignment(0.95, 0.2),
[Color(0xFF3D7DF5), Color(0xFF5A9EFF)],
);
const Map<String, TagGradient> _tagColorMap = {
'单选题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF3D7DF5), Color(0xFF5A9EFF)],
),
'多选题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF3DB8F5), Color(0xFF5AC8FF)],
),
'判断题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF3FC8A6), Color(0xFF89E9D1)],
),
'填空题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF66D969), Color(0xFF93E594)],
),
'完形填空': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF81B84F), Color(0xFFB1E384)],
),
'阅读理解': TagGradient(
Alignment(-0.44, -0.8), // 126.17deg
Alignment(0.44, 0.8),
[Color(0xFFFFC260), Color(0xFFFF9E45)],
),
'听力理解': TagGradient(
Alignment(-0.44, -0.8),
Alignment(0.44, 0.8),
[Color(0xFFFFA052), Color(0xFFFF6F3A)],
),
'7选5': TagGradient(
Alignment(-0.44, -0.8),
Alignment(0.44, 0.8),
[Color(0xFFFF7A41), Color(0xFFFE5837)],
),
'解答题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFFBA59EB), Color(0xFFCD8AEF)],
),
'作文题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF8845E6), Color(0xFFA87BF5)],
),
'问答题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF6864E4), Color(0xFF8582E9)],
),
};
/// Vue: getTagColor(tag)
TagGradient getTagColor(String? tag) {
if (tag == null || tag.isEmpty) return _defaultTagGradient;
return _tagColorMap[tag] ?? _defaultTagGradient;
}
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/api.dart';
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
import 'package:new_learn/core/api/public_upload/model.dart';
@@ -176,6 +177,13 @@ class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
.getListByRecordHandout(_recordId);
}
_pageList = List<HandoutItem>.from(resp.items);
// _pageList = List<HandoutItem>.from(resp.items).map((item) {
// return item.copyWith(
// content: item.content +
// '<div class="hxdb-stem" name="hxdbStem"><div class="hxdb-stem-content" name="hxdbStem-content" data-hxdb-type="stem"><p>某兴趣小组利用 Pt1000 铂电阻测量温度。实验中没有电压表,现将电流表 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/878136bf67384b3ab5998cc6fc15bc32_0_398c9b77.svg" width="25" height="17" style="width: 25px; height: 17px; vertical-align: middle;"> 与已知电阻 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/5a406b64eafd47f3a8686bba4741c29a_1_b71473bc.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 串联组成一条支路,将电流表 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/167759d79f984e45a0d48c31a3c6566e_2_fb3fd646.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 与待测铂电阻 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/f769c572150d4ce782daa8935a9ceb83_3_b1a4eeff.svg" width="23" height="16" style="width: 23px; height: 16px; vertical-align: middle;"> 串联组成另一条支路,两支路并联。忽略两电流表内阻,<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/d37447ecfe6440d1bd97e900cacf3b64_4_398c9b77.svg" width="25" height="17" style="width: 25px; height: 17px; vertical-align: middle;">、<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/8a5d7731a3b744d193a514eeb253ca92_5_fb3fd646.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 的示数分别为 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/9265c18c28934f61bc64bef449f1e641_6_6c3f86e2.svg" width="19" height="16" style="width: 19px; height: 16px; vertical-align: middle;">、<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/b0fdd5ecc5a0477eb7a245d2c0fcf041_7_93189365.svg" width="20" height="17" style="width: 20px; height: 17px; vertical-align: middle;">。铂电阻阻值 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/340415fadcf24d87a7ed79eb81a64c30_8_b1a4eeff.svg" width="23" height="16" style="width: 23px; height: 16px; vertical-align: middle;"> 的表达式为</p><p><br><img src="https://hx.cnzhyx.cn/K12/Question/png/2026-08-24/85cb7f0925a841c6b894ce351678be4f.png" class="preview-content" style="max-width:100%;"><br></p></div><table class="hxdb-option-table" name="hxdbOptionTable" style="width: 100%; table-layout: fixed; border-collapse: collapse;"><tbody><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">A.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/3094744dbf1644bb80aceae639e61c6a_9_7f8b9fc5.svg" width="92" height="40" style="width: 92px; height: 40px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">B.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/96acf8a47eda4cdf81ef01707a28abff_10_fa759021.svg" width="92" height="40" style="width: 92px; height: 40px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">C.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/aecc386b27d341e393bbc45ac86e9e20_11_7cd89d59.svg" width="144" height="42" style="width: 144px; height: 42px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">D.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/6c8a4a6004f04529b9d6b48b4d7560c3_12_1f4bbe90.svg" width="144" height="42" style="width: 144px; height: 42px; vertical-align: middle;"></td></tr></tbody></table></div>',
// );
// }).toList();
// _pageList = List<HandoutItem>.from(resp.items).map((item) {
// return item.copyWith(
// isUploadNotes: true,
@@ -480,8 +488,17 @@ class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
}
void _goTransitionPage() {
// TODO: 跳转到 transition 页,等路由配置好后替换
Navigator.of(context).pop();
// 判断下一个状态:课前(1)→课中(2)→课后(3)
// 此处简化:完成学习后默认进入课后练习过渡页(status=3)
context.push(
'/conquerPackage/transitionPage',
extra: {
'courseId': _courseId,
'packId': _packId,
'recordId': _recordId,
'status': '3',
},
);
}
// ===================== 笔记变化 =====================
@@ -6,6 +6,7 @@ import 'package:new_learn/core/api/conquerPackage/captureList/api.dart';
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
/// ============================================================
/// 【CaptureListPage】攻克列表页 —— 对标 Vue pages/conquerPackage/captureList/index.vue
@@ -234,8 +235,8 @@ class _CaptureListPageState extends State<CaptureListPage> {
if (res.hasQuestion) {
// 有题目 → 过渡页 或 习题页
final pagePath = res.nowStudyObjectType == 0
? '/conquer/transitionPage'
: '/conquer/classExercises';
? '/conquerPackage/transitionPage'
: '/conquerPackage/classExercises';
_navigateTo(pagePath, {
'status': '3',
'courseId': widget.courseId,
@@ -272,12 +273,15 @@ class _CaptureListPageState extends State<CaptureListPage> {
context.push(path, extra: params);
}
/// 点击报告(跳转报告页)
/// 点击报告(跳转报告页,对标 Vue getReport)
/// recordId=攻克记录 id,packId=攻克包 id;判题中(status==3)不可点
void _getReport(CourseRecord record, _CapturePackViewModel pack) {
if (CaptureRecordStatus.fromInt(record.status) ==
CaptureRecordStatus.processing) return;
// TODO(路由): 跳转报告页
debugPrint('跳转报告页 recordId=${record.id}, packId=${pack.id}');
_navigateTo('/conquerPackage/report', {
'recordId': '${record.id}',
'packId': pack.id,
});
}
// ========== 工具方法 ==========
@@ -287,16 +291,6 @@ class _CaptureListPageState extends State<CaptureListPage> {
return chineseNum[num.clamp(0, 9)];
}
String _formatDuration(int seconds) {
if (seconds <= 0) return '0秒';
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
final s = seconds % 60;
if (h > 0) return '${h}时${m}分';
if (m > 0) return '${m}分${s}秒';
return '${s}秒';
}
// ========== 构建 ==========
@override
@@ -888,7 +882,7 @@ class _CaptureListPageState extends State<CaptureListPage> {
),
SizedBox(width: 13.w),
Text(
_formatDuration(0), // TODO: 后端 record 没有用时字段,需确认
formatDuration(record.overcomeTimeSecond),
style: TextStyle(fontSize: 12.sp, color: _subtitleColor),
),
],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
/// ============================================================
/// 【课堂习题 · 收藏 / 错题本】(1:1 迁移 Vue exerciseCollect.js)
///
/// type=1 收藏;type=2 错题(仅 view 模式可用)。
/// ============================================================
library;
import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
class ExerciseCollectHandlers {
ExerciseCollectHandlers({
required this.session,
required this.bridge,
});
final ExerciseSession session;
final ExerciseUiBridge bridge;
final ClassExercisesApi _api = ClassExercisesApi.instance;
dynamic _field(dynamic resp, String key) {
if (resp is Map) {
if (resp[key] != null) return resp[key];
final data = resp['data'];
if (data is Map && data[key] != null) return data[key];
}
return null;
}
/// type:1=收藏,2=错题本
Future<void> onClickAddUserCollect(int type) async {
// 错题仅查看模式可用
if (type == 2 && !session.isView) return;
final info = session.currentQuestion;
if (info == null) return;
final selected = type == 1 ? info.isCollect : info.isWrongCollect;
try {
if (selected) {
final id = type == 1 ? info.collectId : info.wrongCollectId;
await _api.cancleCollect(<String, dynamic>{
'type': type,
'id': id ?? 0,
});
_syncCollectState(type, true, null, info);
bridge.toast(type == 1 ? '取消收藏成功' : '取消错题成功');
} else {
final packId =
session.totalPackId.isNotEmpty ? session.totalPackId : session.packId;
final res = await _api.addUserCollect(<String, dynamic>{
'type': type,
'packId': packId,
'questionId': info.questionId,
'recordQuestionId': info.id,
});
final resId = _field(res, 'id');
_syncCollectState(type, false, resId, info);
bridge.toast(type == 1 ? '收藏成功' : '错题添加成功');
}
bridge.notifyListeners();
} catch (_) {
bridge.toast(type == 1 ? '收藏失败' : '失败');
}
}
/// 同步收藏态(对标 syncCollectState)
void _syncCollectState(
int type,
bool wasSelected,
dynamic resId,
QuestionModel info,
) {
if (type == 2) {
info.isWrongCollect = !wasSelected;
info.wrongCollectId = wasSelected ? 0 : resId;
} else {
info.isCollect = !wasSelected;
info.collectId = wasSelected ? 0 : resId;
}
}
}
@@ -0,0 +1,172 @@
/// ============================================================
/// 【课堂习题 · 数据加载】(1:1 迁移 Vue exerciseLoad.js)
///
/// - loadStartOvercome:edit 模式,GetRecordData + GetListByRecordQuestion,
/// 解析题目、追加 sort、补 hasOptionChildrenQuestion、挂学科 id;
/// - loadSubmitReport:view 模式,GetSubmitReport,错题模式过滤重排。
///
/// 调试:loadStartOvercome 在接口返回后会(仅 kDebugMode)追加
/// assets/mock/class_exercises_edit.json(取自 Vue exerciseLoad.js)。
/// ============================================================
library;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
import 'package:new_learn/core/utils/question/exam_answer_card.dart';
import 'package:new_learn/core/utils/question/question_constant.dart';
import 'package:new_learn/core/utils/question/question_parser.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_sort.dart';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
class ExerciseLoadResult {
const ExerciseLoadResult({
required this.recordId,
required this.navBarTitle,
required this.baseSubjectId,
required this.questions,
required this.originalMaxSort,
});
final String recordId;
final String navBarTitle;
final int? baseSubjectId;
final List<QuestionModel> questions;
/// view 全量题号最大值(错题模式下供页面保留原始题号总数)
final int originalMaxSort;
}
class ExerciseLoad {
ExerciseLoad._();
/// edit 模式加载(对标 loadStartOvercome)
///
/// [inputRecordId] 路由带入的 recordId;[currentIndex] 为起始大题索引,
/// 该题会写入当前 startTime;[baseSubjectIdOverride] 为路由/base 传入的
/// 学科 id(对应 Vue 的 baseSubjectId ?? resRecord.baseSubjectId)。
static Future<ExerciseLoadResult> loadStartOvercome({
required String inputRecordId,
int currentIndex = 0,
int? baseSubjectIdOverride,
}) async {
final api = ClassExercisesApi.instance;
final resRecord = await api.getRecordData(inputRecordId);
final resList = await api.getListByRecordQuestion(inputRecordId);
// 接口照常请求;debug 测试时再把 exerciseLoad.js 里的 JSON 试题追加进去
await _injectDebugQuestions(resList.items);
final navBarTitle = '${resRecord.name}专项智能攻克';
final subjectId = baseSubjectIdOverride ?? resRecord.baseSubjectId;
final subjectType = getSubjectTypeById(subjectId) ?? 0;
final parsed = <Map<String, dynamic>>[];
for (var i = 0; i < resList.items.length; i++) {
final item = resList.items[i];
if (i == currentIndex) {
item['startTime'] = getCurrentISO();
}
// 1=图片作答 / 手写;0=输入框
item['iSFilllankItemUpload'] = subjectType;
parsed.add(parseQuestionData(item, mergeOriginalChilds: true));
}
var list = appendQuestionSort(parsed);
list = appendExamQuestionChildOptionStatus(list, subjectId);
final bsid = subjectId;
for (final q in list) {
q['baseSubjectId'] = bsid;
}
return ExerciseLoadResult(
recordId: resRecord.recordId,
navBarTitle: navBarTitle,
baseSubjectId: bsid,
questions: list.map(QuestionModel.new).toList(),
originalMaxSort: 0,
);
}
/// view 模式加载(对标 loadSubmitReport)
///
/// [isWrong] 为 true 时只保留答错题并重排 wrongSort,同时把起始索引置 0
/// (调用方不要再用旧 currentIndex)。
static Future<ExerciseLoadResult> loadSubmitReport({
required String packId,
bool isWrong = false,
int currentIndex = 0,
}) async {
final api = ClassExercisesApi.instance;
final res = await api.getSubmitReport(packId);
final navBarTitle = '${res.name}专项智能攻克';
final subjectType = getSubjectTypeById(res.baseSubjectId) ?? 0;
final parsed = <Map<String, dynamic>>[];
for (var i = 0; i < res.questions.length; i++) {
final item = res.questions[i];
if (i == currentIndex) {
item['startTime'] = getCurrentISO();
}
item['iSFilllankItemUpload'] = subjectType;
parsed.add(parseQuestionData(item, mergeOriginalChilds: true));
}
var fullList = appendQuestionSort(parsed);
fullList = appendExamQuestionChildOptionStatus(fullList, res.baseSubjectId);
var maxSort = 0;
for (final item in fullList.map(QuestionModel.new).toList()) {
final end = getQuestionSortRange(item).end;
if (end > maxSort) maxSort = end;
}
List<QuestionModel> questions;
if (isWrong) {
questions = filterWrongQuestions(fullList.map(QuestionModel.new).toList());
} else {
questions = fullList.map(QuestionModel.new).toList();
}
for (final q in questions) {
q.baseSubjectId = res.baseSubjectId;
}
return ExerciseLoadResult(
recordId: res.id,
navBarTitle: navBarTitle,
baseSubjectId: res.baseSubjectId,
questions: questions,
originalMaxSort: maxSort,
);
}
/// 【仅 debug 测试】真实接口请求完成后,把 Vue exerciseLoad.js 里的
/// JSON 试题追加到列表尾部(对标 JS 里 `res.push(...listX)`)。
/// release 包直接 no-op;测完删掉 [loadStartOvercome] 里的调用即可。
static Future<void> _injectDebugQuestions(
List<Map<String, dynamic>> items,
) async {
if (!kDebugMode) return;
try {
final raw = await rootBundle
.loadString('assets/mock/class_exercises_edit.json');
final list = jsonDecode(raw);
if (list is List) {
for (final item in list) {
if (item is Map) {
items.add(Map<String, dynamic>.from(item));
}
}
debugPrint('[ExerciseLoad] 已追加 ${list.length} 道测试 JSON 试题');
}
} catch (e) {
debugPrint('[ExerciseLoad] 测试 JSON 追加失败: $e');
}
}
}
@@ -0,0 +1,418 @@
/// ============================================================
/// 【课堂习题 · 翻页 / 子题切换 / 答题卡定位】
///
/// 1:1 迁移 Vue exerciseNavigation.js(createNavigationHandlers)。
///
/// Flutter 端 PageView 使用 NeverScrollableScrollPhysics,所有翻页都由
/// 本控制器经 [ExerciseUiBridge.jumpToPage] 主动触发(对标程序 slideTo),
/// PageView.onPageChanged 只接 [handlePageChange] 做状态同步,不再重复提交。
/// ============================================================
library;
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_sort.dart' as sort;
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_submit.dart';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
class ExerciseNavigationHandlers {
ExerciseNavigationHandlers({
required this.session,
required this.bridge,
required this.submit,
required this.openTimePopup,
required this.openUploadNotesPopup,
required this.submitExamPaper,
});
final ExerciseSession session;
final ExerciseUiBridge bridge;
/// 单题提交控制器(calculateRemainTime / QuestionRenewTimeFn)
final ExerciseSubmitHandlers submit;
/// 页面级“最低学习时长”拦截(含 isWrong 判断)
final bool Function() openTimePopup;
/// 页面级“上传笔记”拦截(含 isWrong 判断)
final bool Function() openUploadNotesPopup;
/// 交卷入口
final void Function(String type) submitExamPaper;
bool _isProgrammaticJump = false;
Timer? _programmaticJumpTimer;
/// 标记程序跳转(400ms 内忽略手势重复触发)
void markProgrammaticJump({Duration duration = const Duration(milliseconds: 400)}) {
_isProgrammaticJump = true;
_programmaticJumpTimer?.cancel();
_programmaticJumpTimer = Timer(duration, () {
_isProgrammaticJump = false;
_programmaticJumpTimer = null;
});
}
bool get isProgrammaticJump => _isProgrammaticJump;
// ---------------- 题号计算 ----------------
sort.SortRange getDisplayQuestionSortRange(int targetIndex) =>
sort.getDisplayQuestionSortRange(session.questions, targetIndex);
sort.ResolvedSortTarget resolveTargetByDisplaySort(int targetSort) =>
sort.resolveTargetByDisplaySort(session.questions, targetSort);
ExerciseSubmitHandlers get _submit => submit;
int get totalSortCount {
if (session.questions.isEmpty) return 0;
return getDisplayQuestionSortRange(session.questions.length - 1).end;
}
int get totalWrongSortCount {
var maxSort = 0;
for (final item in session.questions) {
final end = sort.getWrongQuestionSortRange(item).end;
if (end > maxSort) maxSort = end;
}
return maxSort;
}
int _safeActiveSubIndex(QuestionModel item) {
final subs = item.subQuestions;
if (subs.isEmpty) return 0;
final raw = session.activeSubIndexMap[session.currentIndex] ?? 0;
return raw < 0
? 0
: (raw > subs.length - 1 ? subs.length - 1 : raw);
}
/// 当前展示题号(currentSortLabel)
int get currentSortLabel {
final item = session.currentQuestion;
if (item == null) return 0;
final start = getDisplayQuestionSortRange(session.currentIndex).start;
if (sort.shouldCountSubQuestions(item)) {
return start + _safeActiveSubIndex(item);
}
return start;
}
/// 错题模式当前题号(currentWrongSortLabel)
int get currentWrongSortLabel {
final item = session.currentQuestion;
if (item == null) return 0;
final start = sort.getWrongQuestionSortRange(item).start;
if (item.hasChild && item.subQuestions.isNotEmpty) {
final sub = item.subQuestions[_safeActiveSubIndex(item)];
return sub.wrongSort ?? start;
}
return start;
}
/// 进度百分比(percentage,ceil)
int get percentage {
if (session.isWrong) {
final total = totalWrongSortCount;
if (total == 0) return 0;
final item = session.currentQuestion;
if (item == null) return 0;
final range = sort.getWrongQuestionSortRange(item);
var current = range.start;
if (sort.shouldCountSubQuestions(item)) {
final sub = item.subQuestions[_safeActiveSubIndex(item)];
current = sub.wrongSort ?? range.start;
}
return (current / total * 100).ceil();
}
final total = totalSortCount;
if (total == 0) return 0;
final item = session.currentQuestion;
if (item == null) return 0;
final start = getDisplayQuestionSortRange(session.currentIndex).start;
var current = start;
if (sort.shouldCountSubQuestions(item)) {
current = start + _safeActiveSubIndex(item);
}
return (current / total * 100).ceil();
}
bool get isPrevDisabled =>
session.currentIndex == 0 ? !canGoPrevSubQuestion() : false;
// ---------------- 切页状态同步 ----------------
/// 大题切页后的状态同步(对标 handlePageChange,不含提交)
void handlePageChange(int index) {
final prevIndex = session.currentIndex;
if (prevIndex != index) {
// 注册表暂停,不遍历 element 树,动画收尾帧调用也安全;
// 旧页 isActive 翻转后 didUpdateWidget 也会暂停一次(双保险)
session.itemRefs[prevIndex]?.currentState?.pauseMedia();
}
session.currentIndex = index;
final q = session.currentQuestion;
if (q != null) {
q.startTime = getCurrentISO();
}
final pendingSub = session.pendingSubIndexMap[index];
if (pendingSub != null) {
session.activeSubIndexMap[index] = pendingSub;
final pageKey = session.itemRefs[index];
WidgetsBinding.instance.addPostFrameCallback((_) {
Future<void>.delayed(const Duration(milliseconds: 500), () {
pageKey?.currentState?.setActiveSubIndex(pendingSub);
session.pendingSubIndexMap.remove(index);
});
});
}
session.countDownKey?.currentState?.reset();
bridge.notifyListeners();
}
bool _hasSubQuestions(QuestionModel? item) =>
sort.shouldCountSubQuestions(item);
int _currentActiveSubIndex(QuestionModel item) {
if (!_hasSubQuestions(item)) return -1;
final total = item.subQuestions.length;
final current = session.activeSubIndexMap[session.currentIndex] ?? 0;
return current < 0
? 0
: (current > total - 1 ? total - 1 : current);
}
/// 跨大题切页前提交当前页(对标 submitCurrentQuestionBeforePageSwitch)
void submitCurrentQuestionBeforePageSwitch() {
_submit.calculateRemainTime();
final item = session.currentQuestion;
if (item == null) return;
// 填空题单个空:直接提交大题
if (item.childs.length == 1 && item.questionTypes == 'tiankongti') {
_submit.questionRenewTimeFn(item, null);
return;
}
// hasOptionChildrenQuestion=false 且图片作答:整题算 1 题
if (item.isFillblankItemUpload == 1 &&
!item.hasOptionChildrenQuestion) {
_submit.questionRenewTimeFn(item, null);
return;
}
// 有小题:提交当前激活子题
if (_hasSubQuestions(item) || item.childs.isNotEmpty) {
final data = session.panelKey?.currentState?.getChildData();
_submit.questionRenewTimeFn(
data?.data ?? QuestionModel(<String, dynamic>{}),
item.id,
);
return;
}
_submit.questionRenewTimeFn(item, null);
}
/// 复合题面板子题切换(对标 handleSubQuestionChange)
void handleSubQuestionChange(int questionIndex, int subIndex) {
final item = questionIndex >= 0 && questionIndex < session.questions.length
? session.questions[questionIndex]
: null;
final subs = item == null
? const []
: (item.subQuestions.isNotEmpty
? item.mapList('subQuestions')
: item.mapList('bkTagMetaList'));
final safe = subs.isEmpty
? 0
: (subIndex < 0 ? 0 : (subIndex > subs.length - 1 ? subs.length - 1 : subIndex));
session.activeSubIndexMap[questionIndex] = safe;
}
bool canGoNextSubQuestion() {
final item = session.currentQuestion;
if (!_hasSubQuestions(item)) return false;
final current = _currentActiveSubIndex(item!);
return current >= 0 && current < item.subQuestions.length - 1;
}
bool canGoPrevSubQuestion() {
final item = session.currentQuestion;
if (!_hasSubQuestions(item)) return false;
return _currentActiveSubIndex(item!) > 0;
}
bool goNextSubQuestion() {
final item = session.currentQuestion;
if (!_hasSubQuestions(item)) return false;
final current = _currentActiveSubIndex(item!);
if (current < 0 || current >= item.subQuestions.length - 1) return false;
_submit.calculateRemainTime();
final data = session.panelKey?.currentState?.getChildData();
_submit.questionRenewTimeFn(
data?.data ?? QuestionModel(<String, dynamic>{}),
session.currentQuestion?.id ?? '',
);
session.activeSubIndexMap[session.currentIndex] = current + 1;
bridge.notifyListeners();
return true;
}
bool goPrevSubQuestion() {
final item = session.currentQuestion;
if (!_hasSubQuestions(item)) return false;
final current = _currentActiveSubIndex(item!);
if (current <= 0) return false;
_submit.calculateRemainTime();
final data = session.panelKey?.currentState?.getChildData();
_submit.questionRenewTimeFn(
data?.data ?? QuestionModel(<String, dynamic>{}),
session.currentQuestion?.id ?? '',
);
session.activeSubIndexMap[session.currentIndex] = current - 1;
bridge.notifyListeners();
return true;
}
/// 答题卡定位(对标 handleAnswerCardSelect)
Future<void> handleAnswerCardSelect(Map<String, dynamic> question) async {
final currentQuestionIndex = session.currentIndex;
final currentInfo = session.currentQuestion;
final currentActiveSubIndex =
currentInfo == null ? -1 : _currentActiveSubIndex(currentInfo);
var targetQuestionIndex = -1;
var targetSubIndex = -1;
final parentIndex = question['parentIndex'];
if (parentIndex is int && parentIndex >= 0) {
targetQuestionIndex = parentIndex;
targetSubIndex = int.tryParse('${question['subIndex'] ?? -1}') ?? -1;
} else {
final displaySort = int.tryParse('${question['sort'] ?? 0}') ?? 0;
if (displaySort == 0) return;
final resolved = resolveTargetByDisplaySort(displaySort);
targetQuestionIndex = resolved.questionIndex;
targetSubIndex = resolved.subIndex;
}
if (targetQuestionIndex < 0) return;
final shouldSubmitBeforeJump =
targetQuestionIndex != currentQuestionIndex ||
(targetQuestionIndex == currentQuestionIndex &&
targetSubIndex >= 0 &&
targetSubIndex != currentActiveSubIndex);
if (!session.isView && shouldSubmitBeforeJump) {
// 只触发提交,不 await,避免跳题卡顿
submitCurrentQuestionBeforePageSwitch();
}
if (targetSubIndex >= 0) {
final targetItem = targetQuestionIndex >= 0 &&
targetQuestionIndex < session.questions.length
? session.questions[targetQuestionIndex]
: null;
final subTotal = targetItem?.subQuestions.length ?? 0;
if (subTotal > 0) {
targetSubIndex = targetSubIndex > subTotal - 1 ? subTotal - 1 : targetSubIndex;
if (targetSubIndex < 0) targetSubIndex = 0;
session.activeSubIndexMap[targetQuestionIndex] = targetSubIndex;
session.pendingSubIndexMap[targetQuestionIndex] = targetSubIndex;
} else {
targetSubIndex = -1;
}
}
if (targetQuestionIndex != currentQuestionIndex) {
markProgrammaticJump();
_submit.calculateRemainTime();
await Future<void>.delayed(const Duration(milliseconds: 500));
bridge.jumpToPage(targetQuestionIndex);
return;
}
if (targetSubIndex >= 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
session.itemRefs[targetQuestionIndex]
?.currentState
?.setActiveSubIndex(targetSubIndex);
});
bridge.notifyListeners();
}
}
/// 触发当前页“上传笔记”
Future<void>? uploadNotes() {
return session.itemRefs[session.currentIndex]
?.currentState
?.uploadLectureNotes();
}
/// 下一题(含复合题内部下一小题)
void nextStep() {
if (canGoNextSubQuestion()) {
goNextSubQuestion();
return;
}
submitCurrentQuestionBeforePageSwitch();
if (session.currentIndex == session.questions.length - 1) return;
markProgrammaticJump();
bridge.jumpToPage(session.currentIndex + 1);
}
/// 上一题(含复合题内部上一小题)
void previousStep() {
if (canGoPrevSubQuestion()) {
goPrevSubQuestion();
return;
}
if (session.currentIndex == 0) return;
submitCurrentQuestionBeforePageSwitch();
markProgrammaticJump();
bridge.jumpToPage(session.currentIndex - 1);
}
/// 顶部返回:通知页面打开退出确认弹窗
void clickBackNavBar() {
bridge.openExitPopup();
}
/// 底部上一题 / 下一题统一入口(对标 switchStep)
void switchStep(String type, [String? timePopupIfNeeded]) {
if (session.currentIndex == 0 &&
type == 'PreviousStep' &&
!canGoPrevSubQuestion()) {
return;
}
if (type == 'NextStep') {
// 最低学习时长只拦“跨大题”,不拦大题内子题切换
if (timePopupIfNeeded != 'minimumLearningDuration') {
if (!canGoNextSubQuestion() && openTimePopup()) return;
}
if (openUploadNotesPopup()) return;
if (currentSortLabel == totalSortCount) {
submitExamPaper('submit');
return;
}
nextStep();
} else if (type == 'PreviousStep') {
previousStep();
}
}
/// view 最后一题“完成”:回攻克列表
void completeEnd() {
bridge.relaunchCaptureList(session.courseId);
}
void dispose() {
_programmaticJumpTimer?.cancel();
}
}
@@ -0,0 +1,136 @@
/// ============================================================
/// 【课堂习题 · 会话状态与 UI 桥接】
///
/// Vue 端各 createXxxHandlers 通过一堆 ref 共享状态;Flutter 端按
/// 用户拍板用“普通 Dart 控制器(无 Riverpod)”,因此把跨控制器共享
/// 的可变状态收敛到 [ExerciseSession],把与 Widget/导航/弹窗相关的
/// 副作用收敛到 [ExerciseUiBridge],由页面 State 实现并注入。
/// ============================================================
library;
import 'package:flutter/widgets.dart';
import 'package:new_learn/features/conquerPackage/classExercises/exercise_question_view.dart';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
import 'package:new_learn/features/conquerPackage/classExercises/widgets/compound_question_panel.dart';
import 'package:new_learn/features/conquerPackage/classExercises/widgets/question_plan_countdown.dart';
/// 当前本地 ISO 时间(对标 getCurrentISO)
String getCurrentISO() => DateTime.now().toIso8601String();
/// 攻克流程弹窗配置(对标 Vue conquerPopupCon)
class ConquerPopupConfig {
ConquerPopupConfig({
this.show = false,
this.type = 'note',
this.title = '',
this.subtitle = '',
this.confirmText = '确定',
this.cancelText = '取消',
this.isConfirm = false,
this.confirmAction = '',
});
bool show;
String type; // note / time / submit / grading
String title;
String subtitle;
String confirmText;
String cancelText;
bool isConfirm;
String confirmAction;
}
/// 跨控制器共享的会话状态(字段名与 Vue 保持一致)
class ExerciseSession {
/// 题目列表(解析后的 QuestionModel,与 Map 同引用语义)
List<QuestionModel> questions = [];
/// 当前大题索引(swiper index)
int currentIndex = 0;
/// edit / view
String mode = 'edit';
String courseId = '';
String packId = '';
String totalPackId = '';
String recordId = '';
bool isWrong = false;
/// view 全量题号(originalMaxSort)
int originalMaxSort = 0;
/// 导航栏标题
String navBarTitle = '';
/// 大题索引 -> 激活子题索引
final Map<int, int> activeSubIndexMap = {};
/// 大题索引 -> 等待页面挂载后定位的子题索引
final Map<int, int> pendingSubIndexMap = {};
/// 已处理过的题目 id(避免重复提交,对应 processedQuestionIds)
final Set<String> processedQuestionIds = <String>{};
/// 单题页 ref(itemRefs)
final Map<int, GlobalKey<ExerciseQuestionViewState>> itemRefs = {};
/// 复合题面板 ref(compoundQuestionFn)
GlobalKey<CompoundQuestionPanelState>? panelKey;
/// 倒计时组件 ref(countDownRef)
GlobalKey<QuestionPlanCountDownState>? countDownKey;
bool get isView => mode == 'view';
/// 当前题(questionsInfo)。Flutter 与列表元素保持同一引用,
/// 不再做 Vue 的浅拷贝。
QuestionModel? get currentQuestion {
if (currentIndex < 0 || currentIndex >= questions.length) return null;
return questions[currentIndex];
}
}
/// 控制器 -> 页面的副作用桥接(页面 State implements 本接口)
abstract class ExerciseUiBridge {
/// 全局 loading(对标 uni.showLoading({mask:true}))
void showLoading([String message]);
void hideLoading();
/// 轻提示(对标 uni.showToast icon:none)
void toast(String message);
/// 通知页面刷新(对标 Vue ref 赋值触发渲染)
void notifyListeners();
/// PageController 切页(对标 swiper.slideTo)
void jumpToPage(int index);
/// 打开“退出攻克”确认框(openExitPopup)
void openExitPopup();
/// 展示攻克流程弹窗(getConquerPopupCon)
void showConquerPopup(ConquerPopupConfig config);
/// 关闭攻克流程弹窗
void hideConquerPopup();
/// 答题卡显隐(showAnswerSheet)
void setAnswerSheetVisible(bool visible);
/// 暂停弹窗显隐(showPausePopup)
void setPausePopupVisible(bool visible);
/// 交卷成功跳报告页(redirectTo report)
void redirectToReport(String recordId, String packId);
/// 返回上 N 页(navigateBack delta)
void navigateBack({int delta});
/// 重启到攻克列表(reLaunch captureList)
void relaunchCaptureList(String courseId);
/// 是否平板(对标 isPadDevice(),time 弹窗字号用)
bool get isPad;
}
@@ -0,0 +1,194 @@
/// ============================================================
/// 【课堂习题 · 题号区间 / 错题过滤】
///
/// 1:1 迁移 Vue exerciseNavigation.js 中的纯计算函数与
/// classExercises.vue 中的 filterWrongQuestions / appendWrongQuestionSort。
/// ============================================================
library;
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
class SortRange {
const SortRange(this.start, this.end);
final int start;
final int end;
}
bool isWrongQuestionStatus(dynamic status) {
if (status == null) return true; // 对标 Number(undefined) != 10
final n = int.tryParse('$status');
return n != 10;
}
/// hasOptionChildrenQuestion === true 且有 subQuestions
bool shouldCountSubQuestions(QuestionModel? item) {
if (item == null) return false;
return item.hasOptionChildrenQuestion && item.subQuestions.isNotEmpty;
}
/// 后端 sort 区间(Vue getQuestionSortRange:hasChild + subQuestions)
SortRange getQuestionSortRange(QuestionModel? item) {
if (item == null) return const SortRange(0, 0);
final subs = item.subQuestions;
if (item.hasChild && subs.isNotEmpty) {
final first = subs.first.sort ?? 0;
final last = subs.last.sort ?? 0;
return SortRange(first, last == 0 ? first : last);
}
final sort = item.sort ?? 0;
return SortRange(sort, sort);
}
/// 错题 wrongSort 区间(Vue getWrongQuestionSortRange)
SortRange getWrongQuestionSortRange(QuestionModel? item) {
if (item == null) return const SortRange(0, 0);
if (shouldCountSubQuestions(item)) {
final subs = item.subQuestions;
if (subs.isEmpty) {
final wrongSort = item.wrongSort ?? 0;
return SortRange(wrongSort, wrongSort);
}
final first = subs.first.wrongSort ?? 0;
final last = subs.last.wrongSort ?? 0;
return SortRange(first, last == 0 ? first : last);
}
final wrongSort = item.wrongSort ?? 0;
return SortRange(wrongSort, wrongSort);
}
/// 展示题号区间(按列表顺序,复合题占 N 个题号)
SortRange getDisplayQuestionSortRange(
List<QuestionModel> questions,
int targetIndex,
) {
var cursor = 1;
for (var i = 0; i < questions.length; i++) {
final item = questions[i];
final count = shouldCountSubQuestions(item) ? item.subQuestions.length : 1;
final start = cursor;
final end = cursor + count - 1;
if (i == targetIndex) return SortRange(start, end);
cursor = end + 1;
}
return const SortRange(0, 0);
}
/// 展示题号 -> 大题索引 + 子题索引
class ResolvedSortTarget {
const ResolvedSortTarget(this.questionIndex, this.subIndex);
final int questionIndex;
final int subIndex; // 非子题为 -1
}
ResolvedSortTarget resolveTargetByDisplaySort(
List<QuestionModel> questions,
int targetSort,
) {
if (targetSort <= 0) return const ResolvedSortTarget(-1, -1);
var cursor = 1;
for (var i = 0; i < questions.length; i++) {
final item = questions[i];
final count = shouldCountSubQuestions(item) ? item.subQuestions.length : 1;
final start = cursor;
final end = cursor + count - 1;
if (targetSort >= start && targetSort <= end) {
return ResolvedSortTarget(
i,
shouldCountSubQuestions(item) ? targetSort - start : -1,
);
}
cursor = end + 1;
}
return const ResolvedSortTarget(-1, -1);
}
String _getIdString(Map<String, dynamic> obj) {
final raw = obj['raw'];
if (raw is Map && raw['id'] != null) return '${raw['id']}';
if (obj['id'] != null) return '${obj['id']}';
if (obj['questionId'] != null) return '${obj['questionId']}';
return '';
}
/// 过滤错题并按原序重排 wrongSort(Vue filterWrongQuestions)
List<QuestionModel> filterWrongQuestions(List<QuestionModel> list) {
final filtered = <QuestionModel>[];
for (final item in list) {
if (shouldCountSubQuestions(item)) {
final subs = item.mapList('subQuestions');
final wrongSubs =
subs.where((sub) => isWrongQuestionStatus(sub['answerStatus'])).toList();
if (wrongSubs.isEmpty) continue;
final childs = item.mapList('childs');
final childById = <String, Map<String, dynamic>>{};
for (final child in childs) {
final key = _getIdString(child);
if (key.isNotEmpty) childById[key] = child;
}
final wrongChilds = <Map<String, dynamic>>[];
for (final sub in wrongSubs) {
final key = _getIdString(sub);
final byId = key.isNotEmpty ? childById[key] : null;
if (byId != null) {
wrongChilds.add(byId);
continue;
}
final subIndex = subs.indexOf(sub);
if (subIndex >= 0 && subIndex < childs.length) {
wrongChilds.add(childs[subIndex]);
continue;
}
wrongChilds.add(sub);
}
filtered.add(QuestionModel({
...item.m,
'subQuestions': wrongSubs,
'childs': wrongChilds,
}));
continue;
}
final childs = item.mapList('childs');
final hasWrongChild = childs.isNotEmpty &&
childs.any((child) => isWrongQuestionStatus(child['answerStatus']));
if (hasWrongChild || isWrongQuestionStatus(item.m['answerStatus'])) {
filtered.add(item);
}
}
int orderSort(QuestionModel item) {
final subs = item.subQuestions;
if (subs.isNotEmpty) return subs.first.sort ?? 0;
return item.sort ?? 0;
}
filtered.sort((a, b) => orderSort(a) - orderSort(b));
return appendWrongQuestionSort(filtered);
}
/// 给过滤后的错题顺序追加 wrongSort(Vue appendWrongQuestionSort)
List<QuestionModel> appendWrongQuestionSort(List<QuestionModel> list) {
var wrongSort = 1;
return list.map((item) {
if (shouldCountSubQuestions(item)) {
final newSubs = item.mapList('subQuestions').map((sub) {
return {
...sub,
'wrongSort': wrongSort++,
};
}).toList();
return QuestionModel({
...item.m,
'subQuestions': newSubs,
});
}
return QuestionModel({
...item.m,
'wrongSort': wrongSort++,
});
}).toList();
}
@@ -0,0 +1,816 @@
/// ============================================================
/// 【课堂习题 · 单题提交 / 失败补提 / 交卷】
///
/// 1:1 迁移 Vue exerciseSubmit.js(createSubmitHandlers)。
///
/// 职责:
/// 1) 单题提交(客观题 QuestionRenewTime / 主观题 PushAIGrading)
/// 2) 填空题(输入框 / 图片作答)统一处理与提交
/// 3) 批量统计未处理 / 未作答题目(batchProcessQuestions)
/// 4) 交卷弹窗与提交入口(submitExamPaper / handlePopupConfirm)
/// 5) 失败队列本地持久化(shared_preferences,key classExercisesDraft:$courseId)
/// ============================================================
library;
import 'dart:async';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:new_learn/core/components/upload.dart';
import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
import 'package:new_learn/core/utils/question/exam_answer_card.dart';
import 'package:new_learn/core/utils/question/question_parser.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
/// 失败补提结果(对标 Vue {success, failedCount})
class RetryResult {
const RetryResult({required this.success, required this.failedCount});
final bool success;
final int failedCount;
}
/// 答题卡额外计数
class AnswerCardExtraCounts {
const AnswerCardExtraCounts({
this.unansweredCount = 0,
this.pendingNotesCount = 0,
});
final int unansweredCount;
final int pendingNotesCount;
}
class _FailedEntry {
_FailedEntry({
required this.key,
required this.submitType,
required this.payload,
});
final String key;
final String submitType; // QuestionRenewTime / PushAIGrading
final Map<String, dynamic> payload;
}
class ExerciseSubmitHandlers {
ExerciseSubmitHandlers({
required this.session,
required this.bridge,
});
final ExerciseSession session;
final ExerciseUiBridge bridge;
final ClassExercisesApi _api = ClassExercisesApi.instance;
bool _submitLock = false;
Timer? _submitLockTimer;
/// 单题提交失败队列(交卷前统一补提)
final Map<String, _FailedEntry> _failedSubmitRequestMap = {};
String get _pendingStorageKey =>
'classExercisesDraft:${session.courseId}';
// ---------------- 失败队列 ----------------
String _buildFailedKey(Map<String, dynamic> payload) {
final id = payload['id'] ?? '';
final childId = payload['childId'] ?? 0;
final index = payload['index'] ?? '';
return '$id|$childId|$index';
}
void _upsertFailedSubmitRequest(
String submitType,
Map<String, dynamic> payload,
) {
final key = _buildFailedKey(payload);
_failedSubmitRequestMap[key] = _FailedEntry(
key: key,
submitType: submitType,
payload: Map<String, dynamic>.from(payload),
);
}
void _removeFailedSubmitRequest(
String submitType,
Map<String, dynamic> payload,
) {
_failedSubmitRequestMap.remove(_buildFailedKey(payload));
}
/// 进入页面时从本地缓存恢复失败队列
Future<void> restorePendingFromStorage() async {
final key = _pendingStorageKey;
if (key.endsWith(':')) return;
_failedSubmitRequestMap.clear();
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(key);
if (raw == null || raw.isEmpty) return;
final cached = jsonDecode(raw);
final items = cached is Map && cached['items'] is List
? cached['items'] as List
: const [];
for (final rawItem in items) {
if (rawItem is! Map) continue;
final payload = rawItem['payload'];
if (payload is! Map) continue;
final submitType = '${rawItem['submitType'] ?? ''}';
if (submitType != 'QuestionRenewTime' &&
submitType != 'PushAIGrading') {
continue;
}
final payloadMap = Map<String, dynamic>.from(payload);
final failedKey = _buildFailedKey(payloadMap);
_failedSubmitRequestMap[failedKey] = _FailedEntry(
key: failedKey,
submitType: submitType,
payload: payloadMap,
);
}
} catch (_) {
// 缓存损坏:忽略,从空队列开始
}
}
/// 把当前失败队列写回本地缓存(空队列时清缓存)
Future<void> persistPendingToStorage() async {
final key = _pendingStorageKey;
if (key.endsWith(':')) return;
try {
final prefs = await SharedPreferences.getInstance();
final entries = _failedSubmitRequestMap.values.toList();
if (entries.isEmpty) {
await prefs.remove(key);
return;
}
final items = entries
.map((e) => {
'key': e.key,
'submitType': e.submitType,
'payload': e.payload,
})
.toList();
await prefs.setString(
key,
jsonEncode({
'version': 1,
'updatedAt': DateTime.now().millisecondsSinceEpoch,
'courseId': session.courseId,
'items': items,
}),
);
} catch (_) {
// 落盘失败不阻塞流程
}
}
/// 补提失败队列,默认 3 轮
Future<RetryResult> retryFailedSubmitRequests({
int maxRetryRounds = 3,
}) async {
for (var round = 0; round < maxRetryRounds; round++) {
final currentFailedList = _failedSubmitRequestMap.values.toList();
if (currentFailedList.isEmpty) {
return const RetryResult(success: true, failedCount: 0);
}
for (final item in currentFailedList) {
try {
final safePayload =
normalizeSubmitTimeRange(Map<String, dynamic>.from(item.payload));
if (item.submitType == 'QuestionRenewTime') {
await _api.questionRenewTime(safePayload);
} else if (item.submitType == 'PushAIGrading') {
await _api.pushAIGrading(safePayload);
}
_failedSubmitRequestMap.remove(item.key);
} catch (_) {
_upsertFailedSubmitRequest(item.submitType, item.payload);
}
}
}
return RetryResult(
success: _failedSubmitRequestMap.isEmpty,
failedCount: _failedSubmitRequestMap.length,
);
}
int getPendingCount() => _failedSubmitRequestMap.length;
// ---------------- 答案回填 ----------------
/// 倒计时结束:仅处理当前可见题
void finishPlanTime(dynamic questionId) {
final current = session.currentQuestion;
final currentQuestionId = current?.id ?? '${session.currentIndex}';
if (questionId != null &&
'$questionId'.isNotEmpty &&
'$questionId' != currentQuestionId) {
return;
}
current?.m['ISFinishPlanTime'] = true;
session.countDownKey?.currentState?.reset();
}
/// 客观题选择答案(e 为含 userAnswer 的回调载荷)
void selectAnswer(dynamic userAnswer, [int? questionIndex]) {
final idx = questionIndex ?? session.currentIndex;
final current = idx >= 0 && idx < session.questions.length
? session.questions[idx]
: null;
if (current != null) current.userAnswer = userAnswer;
}
/// 单题答案更新(文本 / 图片 JSON 字符串)
void updateAnswer(dynamic value, [int? questionIndex]) {
final idx = questionIndex ?? session.currentIndex;
final current = idx >= 0 && idx < session.questions.length
? session.questions[idx]
: null;
if (current != null) current.userAnswer = value;
}
/// 复合题:子题作答内容回填(startTime / userAnswer)
void getMyMinAnswer({required int index, required QuestionModel data}) {
final current = session.currentQuestion;
final subs = current?.mapList('subQuestions') ?? const [];
if (index >= 0 && index < subs.length) {
subs[index]['startTime'] = data.m['startTime'];
subs[index]['userAnswer'] = data.m['userAnswer'];
}
}
/// 切题 / 切子题前,把当前倒计时剩余秒数写回题目
void calculateRemainTime() {
final info = session.currentQuestion;
if (info == null) return;
if (info.planTimeSecond > 0 && !info.isFinishPlanTime) {
final countdown = session.countDownKey?.currentState;
if (countdown == null) return;
final remainMs = countdown.getRemainTime();
final remain = (remainMs < 0 ? 0 : remainMs) ~/ 1000;
info.newPlanTimeSecond = remain;
}
}
/// 填空题(图片作答):subQuestions / childs 双写当前空的图片 JSON
void fillBlankImgChange(
List<dynamic> images,
int index, [
int? questionIndex,
]) {
final idx = questionIndex ?? session.currentIndex;
if (idx < 0 || idx >= session.questions.length) return;
final res = session.questions[idx];
final subs = res.mapList('subQuestions');
final childs = res.mapList('childs');
final value = jsonEncode(images);
if (index >= 0 && index < subs.length) {
subs[index]['userAnswer'] = value;
}
if (index >= 0 && index < childs.length) {
childs[index]['userAnswer'] = value;
}
}
// ---------------- 时间范围 / 提交分发 ----------------
/// endTime 不能早于 startTime(对标 normalizeSubmitTimeRange)
Map<String, dynamic> normalizeSubmitTimeRange(
Map<String, dynamic> queryData,
) {
final qd = Map<String, dynamic>.from(queryData);
if (qd['startTime'] == null || '${qd['startTime']}'.isEmpty) {
qd['startTime'] = getCurrentISO();
}
if (qd['endTime'] == null || '${qd['endTime']}'.isEmpty) {
qd['endTime'] = getCurrentISO();
}
final startRaw = qd['startTime'];
final endRaw = qd['endTime'];
if (startRaw == null || endRaw == null) return qd;
final startMs = DateTime.tryParse('$startRaw')?.millisecondsSinceEpoch;
final endMs = DateTime.tryParse('$endRaw')?.millisecondsSinceEpoch;
if (startMs == null || endMs == null) return qd;
if (endMs <= startMs) {
qd['endTime'] =
DateTime.fromMillisecondsSinceEpoch(startMs + 1000, isUtc: true)
.toIso8601String();
}
return qd;
}
/// 提交分发:userAnswer+answerStatus 走 RenewTime;answerImgs 走 PushAIGrading
Future<void> submitQuestionsReview(
Map<String, dynamic> queryData, {
bool throwOnError = false,
}) async {
if ('${queryData['id'] ?? ''}'.isEmpty) return;
final safeQueryData = normalizeSubmitTimeRange(queryData);
if (safeQueryData['startTime'] == null ||
safeQueryData['endTime'] == null) {
return;
}
if (queryData.containsKey('userAnswer') &&
queryData.containsKey('answerStatus')) {
try {
await _api.questionRenewTime(safeQueryData);
_removeFailedSubmitRequest('QuestionRenewTime', safeQueryData);
} catch (err) {
_upsertFailedSubmitRequest('QuestionRenewTime', safeQueryData);
if (throwOnError) rethrow;
}
}
if (queryData.containsKey('answerImgs')) {
try {
await _api.pushAIGrading(safeQueryData);
_removeFailedSubmitRequest('PushAIGrading', safeQueryData);
} catch (err) {
_upsertFailedSubmitRequest('PushAIGrading', safeQueryData);
if (throwOnError) rethrow;
}
}
}
/// AI 判题提交(主观题 / 图片作答)
/// [id] 非空表示子题提交(id=父题 id,childId=子题 id)
Future<void> pushAIGradingFn(
QuestionModel data,
String? id, {
bool throwOnError = false,
}) async {
session.processedQuestionIds.add(data.id);
List<dynamic> answerImgs;
if (id != null && id.isNotEmpty) {
final userAnswer = data.userAnswer;
final List<dynamic> parsed = userAnswer.isEmpty
? const []
: (jsonDecode(userAnswer) as List? ?? const []);
answerImgs = parsed.whereType<Map>().map((raw) {
return {
'url': raw['Url'] ?? raw['url'],
'thumbnailUrl': raw['ThumbnailUrl'] ?? raw['thumbnailUrl'],
};
}).toList();
} else {
final pageState = session.itemRefs[session.currentIndex]?.currentState;
final rawList = pageState?.getImageList('uploadAnswerRef') ?? const [];
answerImgs = _normalizeAnswerImgs(rawList);
}
final queryData = <String, dynamic>{
'id': (id != null && id.isNotEmpty) ? id : data.id,
'childId': (id != null && id.isNotEmpty) ? data.id : 0,
'courseId': session.courseId,
'startTime': data.m['startTime'],
'endTime': getCurrentISO(),
'answerImgs': answerImgs,
};
await submitQuestionsReview(
queryData,
throwOnError: throwOnError,
);
}
List<Map<String, dynamic>> _normalizeAnswerImgs(List<dynamic> rawList) {
final result = <Map<String, dynamic>>[];
for (final raw in rawList) {
if (raw is UploadItem) {
if (raw.url.isEmpty) continue;
result.add({
'url': raw.url,
'thumbnailUrl': raw.thumbnailUrl ?? raw.url,
});
} else if (raw is Map) {
final url = '${raw['Url'] ?? raw['url'] ?? ''}';
if (url.isEmpty) continue;
result.add({
'url': url,
'thumbnailUrl': '${raw['ThumbnailUrl'] ?? raw['thumbnailUrl'] ?? url}',
});
}
}
return result;
}
/// 填空题图片作答提交(单空 / 多空统一走 answerImgs)
Future<void> handleFillBlankAnswer(
QuestionModel data,
String? id, {
bool throwOnError = false,
}) async {
final userAnswer = data.userAnswer;
final answerImgs = userAnswer.isEmpty
? const []
: (jsonDecode(userAnswer) as List? ?? const []);
final queryData = <String, dynamic>{
'id': (id != null && id.isNotEmpty) ? id : data.id,
'childId': 0,
'courseId': session.courseId,
'startTime': data.m['startTime'],
'endTime': getCurrentISO(),
'answerImgs': answerImgs,
};
await submitQuestionsReview(
queryData,
throwOnError: throwOnError,
);
}
/// 题目提交入口(对标 QuestionRenewTimeFn)
Future<void> questionRenewTimeFn(
QuestionModel data,
String? id, {
bool throwOnError = false,
}) async {
if (session.isView) return;
session.processedQuestionIds.add(data.id);
// 非客观题 非填空 → AI 判题
if (data.options.isEmpty && data.questionTypes != 'tiankongti') {
await pushAIGradingFn(data, id, throwOnError: throwOnError);
return;
}
// 填空题(图片作答)
if (data.isFillblankItemUpload == 1 &&
data.questionTypes == 'tiankongti') {
await handleFillBlankAnswer(data, id, throwOnError: throwOnError);
return;
}
// 客观题:对比前剥离 HTML(与 Vue 一致,直接回写题目数据)
data.correctAnswer = stripAnswerPlain(data.correctAnswer);
data.userAnswer = stripAnswerPlain(data.userAnswer);
var answerStatus = 0;
if (formatQuestionText(data.userAnswer) ==
formatQuestionText(data.correctAnswer)) {
answerStatus = 10;
}
if (formatQuestionText(data.userAnswer) !=
formatQuestionText(data.correctAnswer)) {
answerStatus = 11;
}
// 多选题必须完全匹配;其他题型保留“包含即对”
if (data.questionTypes != 'duoxuanti' &&
data.correctAnswer.contains(data.userAnswer) &&
data.options.isNotEmpty) {
answerStatus = 10;
}
if ('${data.m['userAnswer'] ?? ''}'.isEmpty) answerStatus = 0;
final queryData = <String, dynamic>{
'id': (id != null && id.isNotEmpty) ? id : data.id,
'childId': (id != null && id.isNotEmpty) ? data.id : 0,
'courseId': session.courseId,
'userAnswer': data.userAnswer,
'startTime': data.m['startTime'],
'endTime': getCurrentISO(),
'answerStatus': answerStatus,
};
// 七选五
if (data.questionTypes == 'qixuanwu_sub' ||
data.questionTypes == 'qixuanwu') {
queryData['childId'] = 0;
queryData['index'] = data.m['index'];
final plainCorrect = stripAnswerPlain(data.correctAnswer);
final plainUser = stripAnswerPlain(data.userAnswer);
queryData['answerStatus'] = plainCorrect.contains(plainUser) ? 10 : 11;
if ('${data.m['userAnswer'] ?? ''}'.isEmpty) {
queryData['answerStatus'] = 0;
}
}
// 填空输入类:走小题
if (data.isFillblankItemUpload == 0 &&
data.questionTypes == 'tiankongti') {
queryData['childId'] = 0;
queryData['index'] = data.m['index'] ?? 1;
queryData['answerStatus'] = formatQuestionText(data.correctAnswer) ==
formatQuestionText(data.userAnswer)
? 10
: 11;
if ('${data.m['userAnswer'] ?? ''}'.isEmpty) {
queryData['answerStatus'] = 0;
}
}
await submitQuestionsReview(
queryData,
throwOnError: throwOnError,
);
}
/// 复合题切子题时回填并提交(对标 seleCthildData)
void selectChildData({required int index, required QuestionModel data}) {
final currentItem = session.currentQuestion;
final subs = currentItem?.mapList('subQuestions') ?? const [];
var submitData = QuestionModel(<String, dynamic>{});
if (index >= 0 && index < subs.length) {
final sub = subs[index];
sub['startTime'] = data.m['startTime'];
sub['userAnswer'] = data.m['userAnswer'];
submitData = QuestionModel(sub);
}
final parentId = session.currentQuestion?.id ?? '';
questionRenewTimeFn(submitData, parentId);
}
// ---------------- 答题卡统计 ----------------
/// 拍平成答题卡题号列表(默认 isExamAnswerCard=true)
List<Map<String, dynamic>> batchProcessQuestions({
bool isExamAnswerCard = true,
}) {
return buildExamAnswerCardQuestions(
session.questions.map((q) => q.m).toList(),
isExamAnswerCard: isExamAnswerCard,
);
}
/// 未作答 / 待补笔记计数(口径与 Vue 一致)
AnswerCardExtraCounts getAnswerCardExtraCounts({
bool isExamAnswerCard = true,
}) {
final list = batchProcessQuestions(isExamAnswerCard: isExamAnswerCard);
var unansweredCount = 0;
var pendingNotesCount = 0;
for (final res in list) {
final userAnswer = res['userAnswer'];
final answerImgs = res['answerImgs'];
final answered = userAnswer != null && '$userAnswer'.isNotEmpty ||
(answerImgs is List && answerImgs.isNotEmpty);
if (!answered) unansweredCount += 1;
if (res['isUploadNotes'] == true) {
final notes = res['notes'];
if (!(notes is List && notes.isNotEmpty)) pendingNotesCount += 1;
}
}
return AnswerCardExtraCounts(
unansweredCount: unansweredCount,
pendingNotesCount: pendingNotesCount,
);
}
// ---------------- 弹窗配置 ----------------
/// 构建并展示攻克流程弹窗(对标 getConquerPopupCon)
void getConquerPopupCon(
String type,
dynamic data, {
String? confirmText,
String? cancelText,
bool? isConfirm,
String? confirmAction,
String? title,
String? subtitle,
}) {
late ConquerPopupConfig config;
if (type == 'note') {
config = ConquerPopupConfig(
show: true,
type: 'note',
title: '本题建议上传过程笔记',
subtitle: '记录解题思维过程,有助于您更好地复盘错题,提升学习效率。',
confirmText: '上传笔记',
cancelText: '取消',
);
} else if (type == 'grading') {
config = ConquerPopupConfig(
show: true,
type: 'grading',
title: '题目批改中',
subtitle: '预计 3~5 分钟有结果',
confirmText: '返回攻克列表',
cancelText: '取消',
);
} else if (type == 'submit') {
config = ConquerPopupConfig(
show: true,
type: 'submit',
title: '确认提交?',
subtitle:
'您还有<span class="spans" style="font-size:32rpx;color:#3D7DF5;"> $data</span> 道题未完成,是否确定提交练习?',
confirmText: '提交',
cancelText: '取消',
);
} else {
// time
final isPad = bridge.isPad;
config = ConquerPopupConfig(
show: true,
type: 'time',
title:
"<div style='font-size:${isPad ? '16px' : '14px'};'>根据你过往数据,建议攻克$data</div>",
subtitle:
"<div style=\"font-size:${isPad ? '13px' : '11px'};\">仓促速学掌握不牢,之后还要重新复习</div>"
"<div style=\"font-size:${isPad ? '13px' : '11px'};\">本次攻克成功,学习时长会逐步减少</div>",
confirmText: '跳过',
cancelText: '继续作答',
confirmAction: 'minimumLearningDuration',
);
}
// con 覆盖项
if (confirmText != null) config.confirmText = confirmText;
if (cancelText != null) config.cancelText = cancelText;
if (isConfirm != null) config.isConfirm = isConfirm;
if (confirmAction != null) config.confirmAction = confirmAction;
if (title != null) config.title = title;
if (subtitle != null) config.subtitle = subtitle;
bridge.showConquerPopup(config);
}
// ---------------- 交卷 ----------------
Future<void> _submitCurrentQuestionBeforeSubmitExamPaper() async {
if (session.isView) return;
final item = session.currentQuestion;
if (item == null) return;
// 填空题单个空:直接提交大题
if (item.childs.length == 1 && item.questionTypes == 'tiankongti') {
await questionRenewTimeFn(item, null, throwOnError: true);
return;
}
// hasOptionChildrenQuestion=false 且图片作答:整题算 1 题
if (item.isFillblankItemUpload == 1 &&
!item.hasOptionChildrenQuestion) {
await questionRenewTimeFn(item, null, throwOnError: true);
return;
}
// 有小题:提交当前激活子题
final hasSub = shouldCountSubQuestionsLocal(item) || item.childs.isNotEmpty;
if (hasSub) {
final childData = session.panelKey?.currentState?.getChildData();
await questionRenewTimeFn(
childData?.data ?? QuestionModel(<String, dynamic>{}),
item.id,
throwOnError: true,
);
return;
}
await questionRenewTimeFn(item, null, throwOnError: true);
}
bool shouldCountSubQuestionsLocal(QuestionModel item) =>
item.hasOptionChildrenQuestion && item.subQuestions.isNotEmpty;
/// 交卷入口(type=='submit' 表示用户已确认,跳过未答确认弹窗)
Future<void> submitExamPaper(String type) async {
if (_submitLock) return;
_submitLock = true;
_submitLockTimer?.cancel();
_submitLockTimer = Timer(const Duration(milliseconds: 18000), () {
_submitLock = false;
_submitLockTimer = null;
});
bridge.showLoading('提交中...');
try {
await _submitCurrentQuestionBeforeSubmitExamPaper();
final counts = getAnswerCardExtraCounts();
if (counts.unansweredCount > 0) {
bridge.setAnswerSheetVisible(false);
getConquerPopupCon(
'submit',
counts.unansweredCount,
confirmText: '确定',
isConfirm: true,
confirmAction: 'showAnswerSheet',
title: '暂时无法提交',
subtitle:
'您还有<span class="spans" style="font-size:32rpx;color:#3D7DF5;"> ${counts.unansweredCount}</span> 道题未作答',
);
return;
}
if (counts.pendingNotesCount > 0) {
bridge.setAnswerSheetVisible(false);
getConquerPopupCon(
'submit',
counts.pendingNotesCount,
confirmText: '确定',
isConfirm: true,
confirmAction: 'showAnswerSheet',
title: '暂时无法提交',
subtitle:
'您还有<span class="spans" style="font-size:32rpx;color:#3D7DF5;"> ${counts.pendingNotesCount}</span> 道题待补笔记',
);
return;
}
final unprocessedList = batchProcessQuestions();
final noAnswer = unprocessedList.where((res) {
final userAnswer = res['userAnswer'];
final answerImgs = res['answerImgs'];
return (userAnswer == null || '$userAnswer'.isEmpty) &&
!(answerImgs is List && answerImgs.isNotEmpty);
}).toList();
if (!(noAnswer.isEmpty || type == 'submit')) {
bridge.setAnswerSheetVisible(false);
getConquerPopupCon('submit', noAnswer.length);
return;
}
if (_failedSubmitRequestMap.isNotEmpty) {
bridge.showLoading('提交中...');
}
final retryResult = await retryFailedSubmitRequests();
if (!retryResult.success) {
bridge.hideLoading();
bridge.toast('网络异常,仍有 ${retryResult.failedCount} 道题提交失败,请稍后重试');
await Future<void>.delayed(const Duration(milliseconds: 1500));
return;
}
await Future<void>.delayed(const Duration(milliseconds: 2000));
bridge.showLoading('提交中...');
bridge.hideLoading();
return;
final res = await _api.recordSubmit(session.recordId);
bridge.setAnswerSheetVisible(false);
if (_field(res, 'code') == -1 || _field(res, 'code') == '-1') {
bridge.hideLoading();
final message = '${_field(res, 'message') ?? ''}';
Future<void>.delayed(const Duration(milliseconds: 500), () {
if (message.isNotEmpty) bridge.toast(message);
});
return;
}
final aiGradingCount = _field(res, 'aiGradingCount');
if (aiGradingCount != null &&
aiGradingCount != 0 &&
'$aiGradingCount' != '0') {
getConquerPopupCon('grading', null);
} else {
bridge.redirectToReport(session.recordId, session.packId);
}
} catch (_) {
bridge.hideLoading();
Future<void>.delayed(const Duration(milliseconds: 500), () {
bridge.toast('网络请求失败,请检查网络');
});
} finally {
_submitLockTimer?.cancel();
_submitLockTimer = null;
bridge.hideLoading();
_submitLock = false;
}
}
dynamic _field(dynamic resp, String key) {
if (resp is Map) {
if (resp[key] != null) return resp[key];
final data = resp['data'];
if (data is Map && data[key] != null) return data[key];
}
return null;
}
/// 弹窗确认按钮分发(对标 handlePopupConfirm)
void handlePopupConfirm(ConquerPopupConfig config) {
if (config.type == 'grading') {
bridge.navigateBack(delta: 3);
} else if (config.type == 'submit') {
if (config.confirmAction == 'showAnswerSheet') {
bridge.hideConquerPopup();
bridge.setAnswerSheetVisible(true);
return;
}
submitExamPaper('submit');
} else if (config.type == 'time' || config.type == 'note') {
bridge.hideConquerPopup();
}
}
/// 最低作答时长提示(返回 true 表示拦截跳题/交卷)
bool openTimePopupIfNeeded() {
final info = session.currentQuestion;
if (info == null) return false;
if (!session.isView &&
info.planTimeSecond > 0 &&
!info.isFinishPlanTime) {
getConquerPopupCon('time', formatTime(info.planTimeSecond));
return true;
}
return false;
}
/// 上传笔记提示(返回 true 表示拦截跳题/交卷)
bool openUploadNotesPopupIfNeeded() {
final info = session.currentQuestion;
if (info != null && info.isUploadNotes && info.notes.isEmpty) {
getConquerPopupCon('note', null);
return true;
}
return false;
}
}
@@ -0,0 +1,171 @@
/// ============================================================
/// 【课堂习题 · 计时】(1:1 迁移 Vue timing.js / exerciseTiming.js)
///
/// - formatElapsed / formatTime / formatDuration 与 Vue 纯函数一致;
/// - ExerciseTiming 对应 createTiming(每秒一次 onTick);
/// - getDisplayPlanTimeSecond 对应 exerciseTiming.js 的同名导出:
/// 优先用本地缓存的剩余秒数 NewPlanTimeSecond,否则用
/// planTimeSecond - answerTimeSecond;<=0 时置 ISFinishPlanTime=true。
/// ============================================================
library;
import 'dart:async';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
String _pad2(int n) => n < 10 ? '0$n' : '$n';
/// mm:ss(不足 1 小时)/ hh:mm:ss(达到 1 小时)
String formatElapsed(int ms) {
final safeMs = ms < 0 ? 0 : ms;
final totalSeconds = safeMs ~/ 1000;
if (totalSeconds < 3600) {
final mm = totalSeconds ~/ 60;
final ss = totalSeconds % 60;
return '${_pad2(mm)}:${_pad2(ss)}';
}
final hh = totalSeconds ~/ 3600;
final mm = (totalSeconds % 3600) ~/ 60;
final ss = totalSeconds % 60;
return '${_pad2(hh)}:${_pad2(mm)}:${_pad2(ss)}';
}
/// 建议学习时长文案:N秒 / N分钟 / N小时 / Nd
String formatTime(int seconds) {
final t = seconds < 0 ? 0 : seconds;
if (t < 60) return '$t秒';
if (t < 3600) return '${t ~/ 60}分钟';
if (t < 86400) return '${t ~/ 3600}小时';
return '${t ~/ 86400}d';
}
/// 已学习时长文案:N秒 / N分钟N秒 / N小时N分钟 / Nd
String formatDuration(int seconds) {
final t = seconds < 0 ? 0 : seconds;
if (t < 60) return '$t秒';
if (t < 3600) {
final m = t ~/ 60;
final s = t % 60;
return '$m分钟$s秒';
}
if (t < 86400) {
final h = t ~/ 3600;
final m = (t % 3600) ~/ 60;
return '$h小时$m分钟';
}
return '${t ~/ 86400}d';
}
/// 计时回调载荷(对标 JS {elapsedMs,text,running})
class TimingTick {
const TimingTick({
required this.elapsedMs,
required this.text,
required this.running,
});
final int elapsedMs;
final String text;
final bool running;
}
/// 页面计时器(对应 createTiming)。
///
/// 注意与 JS 保持一致的几个细节:
/// - pause() 不取消周期定时器,仅冻结累计值(running=false),
/// 周期回调继续按冻结值触发;
/// - start() 已在计时时直接返回;
/// - stop() 才真正取消定时器;
/// - reset() 不清运行状态,只把累计值归零。
class ExerciseTiming {
ExerciseTiming({this.onTick, this.intervalMs = 1000});
final void Function(TimingTick tick)? onTick;
final int intervalMs;
Timer? _timer;
bool _running = false;
int _lastStartAt = 0;
int _elapsedMs = 0;
void _emit() {
final cb = onTick;
if (cb == null) return;
final now = DateTime.now().millisecondsSinceEpoch;
final current = _running ? _elapsedMs + (now - _lastStartAt) : _elapsedMs;
cb(TimingTick(
elapsedMs: current,
text: formatElapsed(current),
running: _running,
));
}
void start() {
if (_timer != null) return;
_running = true;
_lastStartAt = DateTime.now().millisecondsSinceEpoch;
_emit();
_timer = Timer.periodic(Duration(milliseconds: intervalMs), (_) => _emit());
}
void pause() {
if (!_running) return;
_elapsedMs += DateTime.now().millisecondsSinceEpoch - _lastStartAt;
_running = false;
_emit();
}
void resume() {
if (_running) return;
_running = true;
_lastStartAt = DateTime.now().millisecondsSinceEpoch;
_emit();
}
void toggle() {
if (_running) {
pause();
} else {
resume();
}
}
void stop() {
_timer?.cancel();
_timer = null;
_running = false;
}
void reset() {
_elapsedMs = 0;
_lastStartAt = DateTime.now().millisecondsSinceEpoch;
_emit();
}
int getElapsedMs() {
final now = DateTime.now().millisecondsSinceEpoch;
return _running ? _elapsedMs + (now - _lastStartAt) : _elapsedMs;
}
bool isRunning() => _running;
}
/// 单题倒计时展示秒数(对应 getDisplayPlanTimeSecond)。
///
/// NewPlanTimeSecond 是切题/暂停前本地缓存的“剩余秒数”,不是总时长;
/// 首次进入题目没有缓存时,用总时长 - 后端累计作答时长。
/// 结果 <=0 时直接视为倒计时结束并回写 ISFinishPlanTime。
int getDisplayPlanTimeSecond(QuestionModel? q) {
if (q == null) return 0;
final hasLocalRemain = q.m['NewPlanTimeSecond'] != null;
final remain = hasLocalRemain
? (q.newPlanTimeSecond ?? 0)
: q.planTimeSecond - q.answerTimeSecond;
if (remain <= 0) {
q.isFinishPlanTime = true;
return 0;
}
return remain;
}
@@ -0,0 +1,123 @@
/// ============================================================
/// 【课堂习题 · 顶部计时与暂停联动】
///
/// 1:1 迁移 Vue exerciseTiming.js(createTimingHandlers):
/// - 顶部计时(可暂停 / 继续),与倒计时组件联动;
/// - 按 recordId 缓存 / 恢复计时进度(实际逻辑按题目 answerTimeSecond
/// 汇总,SP 缓存代码在 Vue 端已注释,这里仍保留 persist 写缓存);
/// - 暂停时统计剩余未答题数,驱动暂停弹窗。
/// ============================================================
library;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_submit.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
class ExerciseTimingHandlers {
ExerciseTimingHandlers({
required this.session,
required this.bridge,
required this.submit,
required this.isPausePopupVisible,
this.onUiTick,
}) {
timing = ExerciseTiming(onTick: (tick) {
final total = timingOffsetMs + tick.elapsedMs;
timingText = formatElapsed(total);
timingRunning = tick.running;
// 页面层据此刷新顶部计时胶囊(对标 Vue ref 赋值触发渲染)
onUiTick?.call();
});
}
final ExerciseSession session;
final ExerciseUiBridge bridge;
final ExerciseSubmitHandlers submit;
/// 每次秒表 tick 后回调页面刷新 UI(计时文案)
final void Function()? onUiTick;
/// 暂停弹窗当前是否展示(对标 showPausePopup.value 读取)
final bool Function() isPausePopupVisible;
late final ExerciseTiming timing;
/// 顶部计时文案
String timingText = '00:00';
/// 计时是否运行中
bool timingRunning = true;
/// 累计偏移(恢复的已学时长)
int timingOffsetMs = 0;
/// 暂停时剩余未答题数
int remainingCount = 0;
/// 恢复计时缓存(Vue 端实际使用题目 answerTimeSecond 汇总)
void restoreTimingCache() {
try {
var totalStudyTime = 0;
for (final item in session.questions) {
totalStudyTime += item.answerTimeSecond;
}
if (totalStudyTime != 0) {
timingOffsetMs = totalStudyTime * 1000;
timingText = formatElapsed(totalStudyTime * 1000);
} else {
timingOffsetMs = 0;
timingText = '00:00';
}
} catch (_) {
timingOffsetMs = 0;
timingText = '00:00';
}
}
/// 持久化计时进度(view 模式 / 无 recordId 不写)
Future<void> persistTimingCache() async {
final keyId = session.recordId;
if (session.isView || keyId.isEmpty) return;
final elapsedMs = timingOffsetMs + timing.getElapsedMs();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'classExercises_timing_$keyId',
// 与 Vue 一致:历史上存的是 JSON 字符串
'{"elapsedMs":$elapsedMs}',
);
} catch (_) {
// 忽略落盘失败
}
}
/// 切换计时(对标 toggleTiming)
void toggleTiming() {
if (isPausePopupVisible()) return;
if (timing.isRunning()) {
timing.pause();
session.countDownKey?.currentState?.pause();
bridge.setPausePopupVisible(true);
final list = submit.batchProcessQuestions();
remainingCount =
list.where((res) => res['answered'] != true).length;
bridge.notifyListeners();
return;
}
timing.resume();
session.countDownKey?.currentState?.start();
}
/// 关闭暂停弹窗并继续(对标 handlePausePopupClose)
void handlePausePopupClose() {
bridge.setPausePopupVisible(false);
if (!timing.isRunning()) timing.resume();
session.countDownKey?.currentState?.start();
}
void dispose() {
timing.stop();
}
}
@@ -0,0 +1,998 @@
/// ============================================================
/// 【ExerciseQuestionView】课后练习单题页
/// (1:1 迁移 Vue pages/conquerPackage/classExercises/classExercisesPage.vue)
///
/// 结构(与 Vue 模板一致):
/// source → 题型标签 → 题干合并音视频 → 题干(含 #subquestion-N 锚点)
/// → 主体(客观选项 / 填空输入或图片作答 / 主观图片作答)
/// → view 态 childs 解析列表 → 大题解析盒 → 底部笔记 Upload
/// → 末尾 80.h + panelHeight 白色占位
///
/// 交互(与 Vue emit / defineExpose 同名同义):
/// - onSelectAnswer(payload) → selectAnswer
/// - onUpdateAnswer(String) → UpdateAnswer
/// - onNotesChange(items) → handleNotesChange
/// - onSubQuestionChange(payload) → subQuestionChange
/// - onStemLinkTap(index) → 题干锚点点击,由父级驱动复合题面板
/// - `GlobalKey<ExerciseQuestionViewState>` 暴露:
/// UploadLectureNotes() / getImageList(type) /
/// setActiveSubIndex(i) / pauseMedia() / scrollToOffset()
///
/// 说明:
/// - 父级应以 ValueKey(data.id) 挂载,切题整体重建;
/// - 锚点滚动:#subquestion-N 点击后 onTapUrl 返回 false,交由
/// flutter_widget_from_html 内置 AnchorRegistry 滚动(等价 Vue
/// emitScrollToTop → outer scroll-view 定位);面板 tab 切换等
/// 外部回灌走 scrollToAnchor(编程式标记位,不再回调父级);
/// - isActive/isAdjacent 变 false 暂停全部音视频(递归 visitChildElements)。
/// ============================================================
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:new_learn/core/components/audio_player_card.dart';
import 'package:new_learn/core/components/media_pause_scope.dart';
import 'package:new_learn/core/components/rich_text_content.dart';
import 'package:new_learn/core/components/upload.dart';
import 'package:new_learn/core/components/video_player_view.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/utils/question/question_parser.dart';
import 'package:new_learn/core/utils/question/tag_color.dart';
import 'models/question_data_adapter.dart';
import 'models/question_model.dart';
import 'widgets/answer_view.dart';
import 'widgets/exercise_styles.dart';
import 'widgets/obj_options.dart';
/// 对应 Vue props.typeMode
enum ExerciseQuestionMode { edit, view }
/// 对应 emit('subQuestionChange', {subIndex, sort})
class SubQuestionChangePayload {
const SubQuestionChangePayload({required this.subIndex, this.sort = 0});
final int subIndex;
final dynamic sort;
}
typedef QuestionSelectCallback = void Function(Map<String, dynamic> payload);
typedef QuestionUpdateAnswerCallback = void Function(String value);
typedef QuestionNotesCallback = void Function(List<UploadItem> items);
typedef QuestionSubChangeCallback = void Function(SubQuestionChangePayload payload);
typedef QuestionStemLinkCallback = void Function(int subIndex);
class ExerciseQuestionView extends StatefulWidget {
const ExerciseQuestionView({
super.key,
required this.data,
this.isActive = false,
this.isAdjacent = false,
this.mode = ExerciseQuestionMode.edit,
this.activeSubIndex = 0,
required this.panelHeight,
this.onSelectAnswer,
this.onUpdateAnswer,
this.onNotesChange,
this.onSubQuestionChange,
this.onStemLinkTap,
});
/// 题目数据(与 questionsList 元素同一 Map 引用)
final QuestionModel data;
final bool isActive;
final bool isAdjacent;
final ExerciseQuestionMode mode;
/// 当前激活子题下标(外部回灌)
final int activeSubIndex;
/// 复合题浮层当前高度(逻辑像素),用于末尾占位。
/// 用 ValueListenable 接收:拖拽面板时只重建占位盒,不整页重建。
final ValueListenable<double> panelHeight;
final QuestionSelectCallback? onSelectAnswer;
final QuestionUpdateAnswerCallback? onUpdateAnswer;
final QuestionNotesCallback? onNotesChange;
final QuestionSubChangeCallback? onSubQuestionChange;
final QuestionStemLinkCallback? onStemLinkTap;
@override
ExerciseQuestionViewState createState() => ExerciseQuestionViewState();
}
class ExerciseQuestionViewState extends State<ExerciseQuestionView> {
final ScrollController _scrollController = ScrollController();
/// 本题媒体暂停注册表:子树内播放器自动注册,切页时直接暂停,
/// 不再运行时递归 visitChildElements(避免 element 树更新期断言)
final MediaPauseRegistry _mediaRegistry = MediaPauseRegistry();
/// 题干 HtmlWidget(锚点滚动用)
final GlobalKey<HtmlWidgetState> _stemHtmlKey = GlobalKey<HtmlWidgetState>();
/// 图片作答 Upload(uploadAnswerRef)
final GlobalKey<UploadState> _answerUploadKey = GlobalKey<UploadState>();
/// 笔记 Upload(uploadRef)
final GlobalKey<UploadState> _notesKey = GlobalKey<UploadState>();
late int _activeSubIndex = widget.activeSubIndex;
/// 图片作答缓存(惰性初始化,保持引用稳定,避免 Upload 被重置)
List<UploadItem>? _answerItems;
/// 笔记图片缓存
List<UploadItem>? _notesItems;
TextEditingController? _blankController;
/// 本次锚点切换是否来自题干点击(点击已由 fwfh 滚动,外部回灌跳过)
int? _tapDrivenIndex;
/// 编程式锚点滚动标记(scrollToAnchor 期间不要再驱动面板)
bool _programmaticAnchor = false;
bool get _isView => widget.mode == ExerciseQuestionMode.view;
bool get _isEdit => widget.mode == ExerciseQuestionMode.edit;
// ===================== 生命周期 =====================
@override
void initState() {
super.initState();
// 对标 watch(activeSubIndex, immediate: true):挂载即尝试定位锚点
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _scrollToAnchorExternal(widget.activeSubIndex);
});
}
@override
void didUpdateWidget(covariant ExerciseQuestionView oldWidget) {
super.didUpdateWidget(oldWidget);
// 正常父级以 ValueKey(data.id) 重建;兜底处理
if (widget.data.id != oldWidget.data.id) {
_activeSubIndex = 0;
_answerItems = null;
_notesItems = null;
pauseMedia();
}
// 对标 watch(isActive) / watch(isAdjacent)
if ((widget.isActive != oldWidget.isActive && !widget.isActive) ||
(widget.isAdjacent != oldWidget.isAdjacent && !widget.isAdjacent)) {
pauseMedia();
}
// 对标 watch(() => props.activeSubIndex)
if (widget.activeSubIndex != _activeSubIndex &&
widget.activeSubIndex != oldWidget.activeSubIndex) {
final ext = widget.activeSubIndex;
if (ext == _tapDrivenIndex) {
// 点击触发的回灌,fwfh 已在点击时滚动
_tapDrivenIndex = null;
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _scrollToAnchorExternal(ext);
});
}
setState(() => _activeSubIndex = ext);
}
}
@override
void dispose() {
_scrollController.dispose();
_blankController?.dispose();
super.dispose();
}
// ===================== 对外暴露(对标 defineExpose) =====================
/// 触发笔记选图(对标 UploadLectureNotes → uploadRef.chooseImage)
Future<void> uploadLectureNotes() async {
return _notesKey.currentState?.chooseImage();
}
/// 取图片列表(对标 getImageList)
/// - type == 'uploadAnswerRef':作答图,兜底 safeImageAnswerList(userAnswer)
/// - 其它:笔记图,兜底 safeImageAnswerList(notes)
List<dynamic> getImageList(String type) {
if (type == 'uploadAnswerRef') {
final imageList = _answerUploadKey.currentState?.getImageList();
if (imageList != null && imageList.isNotEmpty) return imageList;
return QuestionDataAdapter.safeImageAnswerList(widget.data.userAnswer);
}
final imageList = _notesKey.currentState?.getImageList();
if (imageList != null && imageList.isNotEmpty) return imageList;
return QuestionDataAdapter.safeImageAnswerList(widget.data.m['notes']);
}
/// 对标 setActiveSubIndex:clamp + emit subQuestionChange
void setActiveSubIndex(int subIndex) {
final total = widget.data.subQuestions.length;
final next = total > 0
? subIndex.clamp(0, total - 1)
: 0;
if (next == _activeSubIndex) return;
setState(() => _activeSubIndex = next);
final sort = widget.data.subAt(next)?.sort ?? 0;
widget.onSubQuestionChange
?.call(SubQuestionChangePayload(subIndex: next, sort: sort));
}
/// 暂停视图内全部音视频(对标 pauseMedia)。
/// 直接调注册表:播放器挂载/卸载已自动维护成员,
/// 无需运行时递归遍历 element 树,build/翻页动画阶段调用同样安全。
void pauseMedia() {
_mediaRegistry.pauseAll();
}
/// 父级直接驱动滚动(对标 emit scrollToOffset → outer scroll-view)
void scrollToOffset(double offset) {
if (!_scrollController.hasClients) return;
final max = _scrollController.position.maxScrollExtent;
_scrollController.jumpTo(offset.clamp(0, max));
}
// ===================== 锚点滚动 =====================
/// 题干链接点击:驱动面板 + 交回 fwfh 滚动锚点(return false)
Future<bool> _onStemTap(String url) async {
if (url.startsWith('#subquestion-')) {
final idx = int.tryParse(url.replaceFirst('#subquestion-', ''));
if (idx != null && idx >= 0) {
_tapDrivenIndex = idx;
widget.onStemLinkTap?.call(idx);
// false:fwfh 继续执行 AnchorRegistry.ensureVisible
return false;
}
}
// 其余链接一律拦截(RichTextContent 默认行为)
return true;
}
/// 外部回灌(面板 tab / 切题恢复):只滚动,不再回调 onStemLinkTap
void _scrollToAnchorExternal(int index, {int retries = 4}) {
bool done = false;
void run(int left) async {
if (!mounted || done) return;
_programmaticAnchor = true;
bool ok = false;
try {
ok = await _stemHtmlKey.currentState
?.scrollToAnchor('subquestion-$index') ??
false;
} finally {
_programmaticAnchor = false;
}
if (ok) {
done = true;
return;
}
if (left > 0 && mounted) {
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) run(left - 1);
});
}
}
run(retries);
}
// ===================== 作答回调 =====================
/// 对标 selectOption(父级写 current.userAnswer = payload.userAnswer)
void _selectOption(Map<String, dynamic> payload) {
if (!_isEdit) return;
widget.onSelectAnswer?.call(payload);
}
/// 对标 onInputBlank:写 data.userAnswer + 第一个 child,emit 纯文本
void _onInputBlank(String value) {
if (!_isEdit) return;
widget.data.userAnswer = value;
final childs = widget.data.childs;
if (childs.isNotEmpty) {
childs.first.userAnswer = value;
}
widget.onUpdateAnswer?.call(value);
}
/// 对标 handleUploadChange:emit UpdateAnswer(JSON.stringify(e))
void _onAnswerUploadChanged(List<UploadItem> items) {
_answerItems = List<UploadItem>.from(items);
widget.onUpdateAnswer
?.call(QuestionDataAdapter.uploadItemsToJson(items));
}
/// 对标 handleNotesChange
void _onNotesChanged(List<UploadItem> items) {
_notesItems = List<UploadItem>.from(items);
widget.onNotesChange?.call(items);
}
List<UploadItem> get _answerUploadItems {
return _answerItems ??= QuestionDataAdapter.toUploadItems(
QuestionDataAdapter.parseImageAnswerList(widget.data.userAnswer),
);
}
List<UploadItem> get _notesUploadItems {
return _notesItems ??= QuestionDataAdapter.toUploadItems(
widget.data.notes,
);
}
// ===================== Build =====================
@override
Widget build(BuildContext context) {
// 题干渲染数据(对标 stemRenderData / mergedAudios / mergedVideos)
final stemMedia =
QuestionDataAdapter.buildStemMedia(widget.data, _activeSubIndex);
final children = <Widget>[
// ---- source ----
if (widget.data.source.isNotEmpty)
Padding(
padding: EdgeInsets.only(bottom: 10.h),
child: Text(
widget.data.source,
style: TextStyle(fontSize: 14.sp, color: ExerciseColors.subtitle),
),
),
// ---- 题型标签 ----
Padding(
padding: EdgeInsets.only(bottom: 10.h),
child: _buildTypeLabel(),
),
// ---- 题干合并音视频 ----
for (final audio in stemMedia.audios)
Padding(
padding: EdgeInsets.symmetric(vertical: 10.h),
child: AudioPlayerCard(
url: '${audio['audioUrl'] ?? audio['url'] ?? ''}',
),
),
for (final video in stemMedia.videos)
Padding(
padding: EdgeInsets.symmetric(vertical: 10.h),
child: VideoPlayerView(
url: '${video['videoUrl'] ?? video['url'] ?? ''}',
),
),
// ---- 题干(line-height 58rpx,margin-b 30rpx)----
Padding(
padding: EdgeInsets.only(bottom: 15.h),
child: RichTextContent(
htmlWidgetKey: _stemHtmlKey,
html: stemMedia.content,
textStyle: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF333333),
height: 58 / 32,
),
onTapUrl: (url) async {
// 编程式滚动期间:锚点链接不拦截(让 fwfh 滚动),也不驱动面板
if (_programmaticAnchor && url.startsWith('#subquestion-')) {
return false;
}
return _onStemTap(url);
},
),
),
];
// ---- 主体(v-if="!data.hasOptionChildrenQuestion")----
if (!widget.data.hasOptionChildrenQuestion) {
_buildBody(children);
}
// ---- childs 解析列表(view,且非 hasOptionChildrenQuestion)----
if (!widget.data.hasOptionChildrenQuestion &&
widget.data.childs.isNotEmpty &&
_isView) {
_buildChildsAnalysis(children);
}
// ---- 大题解析盒(仅 view)----
if (_isView) {
_appendMainAnalysis(children);
}
// ---- 底部笔记 Upload ----
children.add(Padding(
padding: EdgeInsets.only(top: isPadDevice(context) ? 40.h : 30.h),
child: Upload(
key: _notesKey,
type: UploadType.questions,
ids: widget.data.id,
mode: _isEdit ? UploadMode.edit : UploadMode.view,
initialImageList: _notesUploadItems,
onChanged: _onNotesChanged,
),
));
return MediaPauseScope(
registry: _mediaRegistry,
child: ClipRRect(
borderRadius: BorderRadius.vertical(top: Radius.circular(14.r)),
child: ColoredBox(
color: Colors.white,
child: SingleChildScrollView(
controller: _scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 对标 .classExercises-main(白底、上圆角 28rpx、padding 40rpx 28rpx)
Container(
width: double.infinity,
color: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
// 对标末尾占位 calc(160rpx + panelHeight px)。
// 仅占位盒随面板高度重建,拖拽不触发整页重建。
ValueListenableBuilder<double>(
valueListenable: widget.panelHeight,
builder: (_, h, __) => Container(
width: double.infinity,
height: 80.h + h,
color: Colors.white,
),
),
],
),
),
),
),
);
}
/// 题型标签(对标 .classExercises-main-label + getTagColor)
Widget _buildTypeLabel() {
final gradient = getTagColor(widget.data.questionTypeName);
return Container(
height: 21.h,
padding: EdgeInsets.symmetric(horizontal: 6.w),
decoration: BoxDecoration(
gradient: gradient.gradient,
borderRadius: BorderRadius.circular(6.r),
),
child: Center(
widthFactor: 1,
child: Text(
widget.data.questionTypeName,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
height: 1,
),
),
),
);
}
// ===================== 主体分支 =====================
void _buildBody(List<Widget> children) {
final data = widget.data;
// 分支 1:客观题(options 非空)
if (data.options.isNotEmpty) {
if (_isEdit) {
children.add(ObjOptions(
options: data.options,
questionType: data.questionTypes,
mode: ObjOptionMode.edit,
answer: data.userAnswer,
onChanged: _selectOption,
));
} else {
children.add(ObjOptions(
options: data.options,
questionType: data.questionTypes,
mode: ObjOptionMode.view,
answerData: ObjOptionAnswerData(
correctAnswer: data.correctAnswer,
myAnswer: data.userAnswer,
),
onChanged: _selectOption,
));
children.add(Padding(
padding: EdgeInsets.only(bottom: 10.h),
child: ObjectiveAnswerView(
answerStatus: data.answerStatus,
userAnswer: data.userAnswer,
correctAnswer: data.correctAnswer,
),
));
}
return;
}
// 分支 2:填空题
if (data.questionTypes == 'tiankongti') {
_buildBlankBody(children);
return;
}
// 分支 3:其他主观题(问答题等,图片作答)
_buildSubjectiveBody(children);
}
/// 填空题主体(对标 v-else-if="questionTypes == 'tiankongti'")
void _buildBlankBody(List<Widget> children) {
final data = widget.data;
if (_isView) {
children.add(_buildStatusStrip(data));
}
if (data.isFillblankItemUpload == 1) {
// 图片作答填空题(注意:单题页 maxCount = 10)
children.add(Padding(
padding: EdgeInsets.only(top: 10.h),
child: Upload(
key: _answerUploadKey,
title: '上传图片作答',
maxCount: 10,
type: UploadType.aiGrading,
ids: data.id,
mode: _isEdit ? UploadMode.edit : UploadMode.view,
initialImageList: _answerUploadItems,
onChanged: _onAnswerUploadChanged,
),
));
if (_isView) {
_appendWrongReasonAdvice(children);
}
} else if (_isEdit) {
children.add(_buildBlankTextarea());
}
if (_isView) {
if (data.isFillblankItemUpload != 1) {
children.add(Padding(
padding: EdgeInsets.only(top: 10.h),
child: _buildAnswerTitle('你的答案'),
));
children.add(_buildAnswerContent(
data.effectiveAnswer,
const Color(0xFF495A7A),
));
}
if (data.hasOptionChildrenQuestion || data.childs.isEmpty) {
children.add(_buildAnswerTitle('正确答案'));
children.add(_buildAnswerContent(
data.correctAnswer.isNotEmpty ? data.correctAnswer : data.answer,
ExerciseColors.correct,
));
}
}
}
/// 主观题主体(问答题等)
void _buildSubjectiveBody(List<Widget> children) {
final data = widget.data;
if (_isView) {
children.add(_buildStatusStrip(data));
}
children.add(Padding(
padding: EdgeInsets.only(top: isPadDevice(context) ? 40.h : 30.h),
child: Upload(
key: _answerUploadKey,
title: '上传图片作答',
maxCount: 10,
type: UploadType.aiGrading,
ids: data.id,
mode: _isEdit ? UploadMode.edit : UploadMode.view,
initialImageList: _answerUploadItems,
onChanged: _onAnswerUploadChanged,
),
));
if (_isView) {
_appendWrongReasonAdvice(children);
if (data.hasOptionChildrenQuestion || data.childs.isEmpty) {
children.add(_buildAnswerTitle('正确答案'));
children.add(_buildAnswerContent(
data.correctAnswer.isNotEmpty ? data.correctAnswer : data.answer,
ExerciseColors.correct,
));
}
}
}
/// 错误原因 / 改进建议(仅 view,answerStatus 11/12)
void _appendWrongReasonAdvice(List<Widget> children) {
final data = widget.data;
final status = data.answerStatus;
if (status != 11 && status != 12) return;
final reason = data.wrongReason;
if (reason != null && reason.isNotEmpty) {
children.add(Padding(
padding: EdgeInsets.only(top: 10.h),
child: _buildAnswerTitle('错误原因'),
));
children.add(_buildPlainContent(reason, const Color(0xFF495A7A)));
}
final advice = data.wrongAdvice;
if (advice != null && advice.isNotEmpty) {
children.add(Padding(
padding: EdgeInsets.only(top: 10.h),
child: _buildAnswerTitle('改进建议'),
));
children.add(_buildPlainContent(advice, const Color(0xFF495A7A)));
}
}
/// 填空题输入框(对标 textarea.auto-height + .input-text)
Widget _buildBlankTextarea() {
final controller = _blankController ??= TextEditingController(
text: QuestionDataAdapter.singleBlankInputValue(widget.data),
);
return Container(
width: double.infinity,
margin: EdgeInsets.only(bottom: 10.h),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h),
decoration: BoxDecoration(
color: ExerciseColors.bg,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: ExerciseColors.border, width: 1.w),
),
child: TextField(
controller: controller,
minLines: 1,
maxLines: null,
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF333333),
),
onChanged: _onInputBlank,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: '请输入您的答案...',
hintStyle: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF94A5B8),
),
),
),
);
}
// ===================== childs 解析列表 =====================
void _buildChildsAnalysis(List<Widget> children) {
final data = widget.data;
final childs = data.childs;
for (var i = 0; i < childs.length; i++) {
final child = childs[i];
children.add(_buildAnswerTitle(
'第${i + 1}${data.questionTypes == 'tiankongti' ? '空' : '题'}',
fontSize: 17.sp,
));
if (child.stem.isNotEmpty) {
children.add(Padding(
padding: EdgeInsets.only(bottom: 5.h),
child: RichTextContent(
html: child.stem,
textStyle: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF333333),
),
),
));
}
children.add(_buildAnswerTitle('正确答案'));
children.add(_buildAnswerContent(
child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer,
ExerciseColors.correct,
));
final analysis = _analysisText(child);
final videos = child.videos;
final analysisVideo = child.answerAnalysisVideo;
final hasVideo = videos.isNotEmpty &&
'${videos.first['videoUrl'] ?? videos.first['url'] ?? ''}'
.isNotEmpty;
final hasAnalysisVideo =
'${analysisVideo?['videoUrl'] ?? ''}'.isNotEmpty;
if (analysis.isNotEmpty || hasVideo || hasAnalysisVideo) {
final content = <Widget>[
if (analysis.isNotEmpty) _buildAnalysisBody(analysis),
if (hasVideo)
for (final video in videos)
Padding(
padding: EdgeInsets.only(top: 10.h),
child: VideoPlayerView(
url: '${video['videoUrl'] ?? video['url'] ?? ''}',
),
),
if (hasAnalysisVideo)
Padding(
padding: EdgeInsets.only(top: 10.h),
child: VideoPlayerView(
url: '${analysisVideo?['videoUrl'] ?? ''}',
),
),
];
children.add(_buildAnalysisBox(content, minHeight: true));
}
}
}
// ===================== 大题解析盒 =====================
void _appendMainAnalysis(List<Widget> children) {
final data = widget.data;
final analysis = _analysisText(data);
final audios = data.audios;
final videos = data.videos;
final analysisVideo = data.answerAnalysisVideo;
final hasVideo = videos.isNotEmpty &&
'${videos.first['videoUrl'] ?? videos.first['url'] ?? ''}'.isNotEmpty;
final hasAnalysisVideo =
'${analysisVideo?['videoUrl'] ?? ''}'.isNotEmpty;
// 对标 v-if:audios || videos.length>0 || answerAnalysisVideo.videoUrl || 解析
if (analysis.isEmpty &&
audios.isEmpty &&
videos.isEmpty &&
!hasAnalysisVideo) {
return;
}
final content = <Widget>[
// 音视频在解析文字之前
for (final audio in audios)
Padding(
padding: EdgeInsets.only(top: 10.h),
child: AudioPlayerCard(
url: '${audio['audioUrl'] ?? audio['url'] ?? ''}',
),
),
if (hasVideo)
for (final video in videos)
Padding(
padding: EdgeInsets.only(top: 10.h),
child: VideoPlayerView(
url: '${video['videoUrl'] ?? video['url'] ?? ''}',
),
),
if (hasAnalysisVideo)
Padding(
padding: EdgeInsets.only(top: 10.h),
child: VideoPlayerView(
url: '${analysisVideo?['videoUrl'] ?? ''}',
),
),
if (analysis.isNotEmpty) _buildAnalysisBody(analysis),
];
children.add(_buildAnalysisBox(content, minHeight: true));
}
/// 对标 .analysis-body(32rpx / 1.55 / #1D2B47,margin 20rpx 0 24rpx)
Widget _buildAnalysisBody(String analysis) {
return Padding(
padding: EdgeInsets.only(top: 10.h, bottom: 12.h),
child: RichTextContent(
html: cleanRichText(analysis),
textStyle: TextStyle(
fontSize: 16.sp,
height: 1.55,
color: ExerciseColors.title,
),
),
);
}
/// 对标 .analysis-box(单题页 CSS:margin 66rpx 0 40rpx、min-height 200rpx)
Widget _buildAnalysisBox(List<Widget> content, {bool minHeight = false}) {
return Container(
constraints: minHeight ? BoxConstraints(minHeight: 100.h) : null,
margin: EdgeInsets.only(top: 33.h, bottom: 20.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
// 对标 border-box 渐变 146.26deg
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF95F8E7), Color(0xFFB9DDFF), Color(0xFF99DCFE)],
stops: [0, 0.4952, 1],
),
),
padding: EdgeInsets.all(1.5.w),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r - 1.5.w),
// 对标 padding-box 渐变 180deg
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE0F9FF), Colors.white, Colors.white],
stops: [0, 0.3, 1],
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
// 对标 padding: 42rpx 28rpx 28rpx
padding: EdgeInsets.fromLTRB(14.w, 21.h, 14.w, 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: content,
),
),
// 对标 .analysis-left-bg(156rpx,left -2rpx,top -36rpx)
Positioned(
left: -1.w,
top: -18.h,
child: CachedNetworkImage(
imageUrl:
ImgUrl.build('/StaticResource/2026-04-16/analysis.png'),
width: 78.w,
placeholder: (_, _) => SizedBox(width: 78.w),
errorWidget: (_, _, _) => SizedBox(width: 78.w),
),
),
// 对标 .analysis-right-icon(180rpx,right -10rpx)
Positioned(
right: -5.w,
top: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-08/analysis-right-icon.png'),
width: 90.w,
placeholder: (_, _) => SizedBox(width: 90.w),
errorWidget: (_, _, _) => SizedBox(width: 90.w),
),
),
],
),
),
);
}
// ===================== 状态条 / 答案标题 / 答案内容 =====================
/// 对标 .obj-options-li-view-correct / error
Widget _buildStatusStrip(QuestionModel data) {
final status = data.answerStatus;
final isCorrect = status == 10;
final text = isCorrect
? '作答正确'
: status == 11
? '作答有误'
: status == 12
? '部分正确'
: '未作答';
final color = isCorrect ? ExerciseColors.correct : ExerciseColors.error;
final icon = isCorrect
? '/StaticResource/2026-06-01/correct-icon1780284329572.png'
: '/StaticResource/2026-06-01/error-icon1780284292405.png';
return GradientBorderBox(
state: isCorrect ? ExerciseTileState.correct : ExerciseTileState.error,
padding: EdgeInsets.all(12.w),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(icon),
width: 20.w,
height: 20.w,
placeholder: (_, _) => SizedBox(width: 20.w, height: 20.w),
errorWidget: (_, _, _) => SizedBox(width: 20.w, height: 20.w),
),
SizedBox(width: 10.w),
Text(
text,
style: TextStyle(fontSize: 16.sp, color: color),
),
],
),
);
}
/// 对标 .answer-box-title(title-bacs.png,bottom right,119rpx)
Widget _buildAnswerTitle(String text, {double? fontSize}) {
return Stack(
children: [
// 标题装饰底图:固定尺寸,位置可调(对标 background-size: 119rpx auto)
Positioned(
right: 10.w,
bottom: 0,
child: Image.network(
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
width: 59.5.w,
height: 9.h,
fit: BoxFit.fill,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
Padding(
padding: EdgeInsets.only(right: 14.w),
child: Text(
text,
style: TextStyle(
fontSize: fontSize ?? 15.sp,
fontWeight: FontWeight.w600,
color: ExerciseColors.title,
),
),
),
],
);
}
/// 对标 .answer-box-content(富文本)
Widget _buildAnswerContent(String html, Color color) {
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: 12.h),
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: ExerciseColors.bg,
borderRadius: BorderRadius.circular(14.r),
),
child: RichTextContent(
html: cleanRichText(html),
textStyle: TextStyle(fontSize: 16.sp, color: color),
),
);
}
/// 对标 .answer-box-content(纯文本:wrongReason / wrongAdvice)
Widget _buildPlainContent(String text, Color color) {
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: 12.h),
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: ExerciseColors.bg,
borderRadius: BorderRadius.circular(14.r),
),
child: Text(
text,
style: TextStyle(fontSize: 16.sp, color: color, height: 1.5),
),
);
}
/// 对标 child.answerAnalysis?.analysis || child.analysis || child.answerAnalysis
String _analysisText(QuestionModel q) {
final raw = q.answerAnalysisRaw;
if (raw is Map) {
final first = '${raw['analysis'] ?? ''}';
if (first.isNotEmpty) return first;
}
if (q.analysis.isNotEmpty) return q.analysis;
if (raw != null && '$raw'.isNotEmpty) return '$raw';
return '';
}
}
@@ -0,0 +1,171 @@
/// ============================================================
/// 题目数据适配层
///
/// 集中处理 QuestionModel 与 UI 组件(RichTextContent / Upload)
/// 之间的数据转换,对标 Vue classExercisesPage.vue 内的
/// stemRenderData / mergedAudios / mergedVideos /
/// getImageAnswerList / getSafeImageAnswerList。
/// ============================================================
library;
import 'dart:convert';
import 'package:new_learn/core/components/upload.dart';
import 'package:new_learn/core/utils/question/extract_media_from_stem.dart';
import 'package:new_learn/core/utils/question/question_parser.dart';
import 'question_model.dart';
/// 题干渲染数据(已格式化 + 已抽取并合并媒体)
class StemRenderData {
const StemRenderData({
required this.content,
this.audios = const [],
this.videos = const [],
});
final String content;
final List<Map<String, dynamic>> audios;
final List<Map<String, dynamic>> videos;
}
class QuestionDataAdapter {
QuestionDataAdapter._();
/// 等价 Vue 页内 stemRenderData + mergedAudios + mergedVideos
static StemRenderData buildStemMedia(QuestionModel q, int activeSubIndex) {
var stem = q.stem.isEmpty ? '暂无题干' : q.stem;
final hasChilds = q.childs.isNotEmpty;
final hasBkTags = q.bkTagMetaList.isNotEmpty;
if (hasChilds || hasBkTags) {
final disableHighlight = !hasChilds && q.bkTagMetaList.length == 1;
final subQuestions =
q.subQuestions.isNotEmpty ? q.subQuestions.map((e) => e.m).toList() : q.childs.map((e) => e.m).toList();
// 图片作答(1)不做填空锚点格式化
if (q.isFillblankItemUpload != 1) {
stem = formatCompoundStem(
stem,
activeSubIndex,
disableHighlight: disableHighlight,
subQuestions: subQuestions,
);
}
// 数学学科:数字占位符转圈号
if ('${q.baseSubjectId}' == '2') {
stem = convertBlankToCircled(stem);
}
}
final media = extractMediaFromStem(stem);
// 合并题干内嵌媒体与 q.audios/q.videos(按 url 去重,保持 JS 顺序)
final audios = <Map<String, dynamic>>[];
for (final item in [...media.audios, ...q.audios]) {
final url = '${item['audioUrl'] ?? item['url'] ?? ''}';
if (url.isEmpty) continue;
if (audios.any((it) => '${it['audioUrl'] ?? it['url']}' == url)) continue;
audios.add(item);
}
final videos = <Map<String, dynamic>>[];
for (final item in [...media.videos, ...q.videos]) {
final url = '${item['videoUrl'] ?? item['url'] ?? ''}';
if (url.isEmpty) continue;
if (videos.any((it) => '${it['videoUrl'] ?? it['url']}' == url)) continue;
videos.add(item);
}
return StemRenderData(
content: media.content.isNotEmpty ? media.content : (q.stem.isNotEmpty ? q.stem : '暂无题干'),
audios: audios,
videos: videos,
);
}
/// 单空填空题输入框值(对标 singleBlankInputValue)
static String singleBlankInputValue(QuestionModel q) {
final direct = q.userAnswer;
if (direct.isNotEmpty) return direct;
for (final child in q.childs) {
final v = '${child.m['userAnswer'] ?? child.m['myAnswer'] ?? ''}';
if (v.isNotEmpty) return v;
}
return '';
}
/// 严格解析:getImageAnswerList(外层保证是 JSON 数组字符串)
static List<Map<String, dynamic>> parseImageAnswerList(dynamic value) {
if (value == null || '$value'.isEmpty) return const [];
dynamic data;
try {
data = value is String ? jsonDecode(value) : value;
} catch (_) {
return const [];
}
if (data is! List) return const [];
return data.whereType<Map>().map((raw) {
final item = Map<String, dynamic>.from(raw);
item['url'] = item['Url'] ?? item['url'];
item['thumbnailUrl'] = item['ThumbnailUrl'] ?? item['thumbnailUrl'];
return item;
}).toList();
}
/// 安全解析:getSafeImageAnswerList(兜底解析 userAnswer / notes)
static List<Map<String, dynamic>> safeImageAnswerList(dynamic value) {
if (value == null || '$value'.isEmpty) return const [];
dynamic list;
try {
list = value is String ? jsonDecode(value) : value;
} catch (_) {
return const [];
}
if (list is! List) return const [];
return list
.whereType<Map>()
.map((raw) {
final item = Map<String, dynamic>.from(raw);
final url = '${item['url'] ?? item['Url'] ?? ''}';
final thumbnailUrl =
'${item['thumbnailUrl'] ?? item['ThumbnailUrl'] ?? url}';
return {...item, 'url': url, 'thumbnailUrl': thumbnailUrl};
})
.where((item) => '${item['url']}'.isNotEmpty)
.toList();
}
/// 后端图片数据 → Upload 组件入参
static List<UploadItem> toUploadItems(List<Map<String, dynamic>> images) {
return images
.where((item) => '${item['url']}'.isNotEmpty)
.map((item) => UploadItem(
id: item['id'],
url: '${item['url']}',
thumbnailUrl: '${item['thumbnailUrl'] ?? item['url']}',
))
.toList();
}
/// Upload 组件当前列表 → 提交用 answerImgs
/// (同时带 url/Url、thumbnailUrl/ThumbnailUrl 兼容后端)
static List<Map<String, dynamic>> uploadItemsToAnswerImgs(
List<UploadItem> items,
) {
return items
.where((e) => e.url.isNotEmpty)
.map((e) => {
'url': e.url,
'Url': e.url,
'thumbnailUrl': e.thumbnailUrl ?? e.url,
'ThumbnailUrl': e.thumbnailUrl ?? e.url,
})
.toList();
}
/// Upload 组件当前列表 → 写回 userAnswer 的 JSON 字符串
/// (对标 Vue handleUploadChange: JSON.stringify(e))
static String uploadItemsToJson(List<UploadItem> items) {
return jsonEncode(uploadItemsToAnswerImgs(items));
}
}
@@ -0,0 +1,267 @@
/// ============================================================
/// 【QuestionModel】题目数据模型(纯 Dart 可变类)
///
/// 设计说明(对应用户口型:纯 Dart 可变类、全量保留原字段名、
/// 手写 fromJson/toJson、不用 Isar/codegen):
/// - 内部以 `Map<String, dynamic>` 承载全部字段(后端字段 + 解析器
/// 动态产出 + 运行时字段),字段名与 Vue 端完全一致;
/// - 解析器(core/utils/question/)直接操作 Map,本类提供类型化
/// get/set 给页面与控制器使用;
/// - childs/subQuestions 每次访问都基于同一批 Map 生成包装对象,
/// 因此通过子模型的写操作会落到原始 Map 上(等价 JS 引用语义)。
/// ============================================================
library;
class QuestionModel {
QuestionModel(this.m);
factory QuestionModel.fromJson(Map<String, dynamic> json) =>
QuestionModel(Map<String, dynamic>.from(json));
/// 原始数据(解析后的 Map,toJson 即提交回这个结构)
final Map<String, dynamic> m;
Map<String, dynamic> toJson() => m;
@override
String toString() => 'QuestionModel(id: $id, questionTypes: $questionTypes)';
// ---------------- 基础读写 ----------------
bool has(String key) => m.containsKey(key) && m[key] != null;
T? get<T>(String key) => m[key] is T ? m[key] as T : null;
void set(String key, dynamic value) => m[key] = value;
String str(String key, [String def = '']) {
final v = m[key];
return v == null ? def : '$v';
}
int? intOf(String key) {
final v = m[key];
if (v is num) return v.toInt();
if (v == null) return null;
return int.tryParse('$v');
}
bool boolOf(String key, [bool def = false]) =>
m[key] == true ? true : (m[key] == false ? false : def);
/// 注意:返回的是原 Map 的 cast 视图(非拷贝),
/// 这样子模型上的所有写操作都会落回原始 JSON Map(等价 JS 引用语义)。
List<Map<String, dynamic>> mapList(String key) {
final v = m[key];
if (v is! List) return const [];
return [
for (final e in v)
if (e is Map) e.cast<String, dynamic>(),
];
}
// ---------------- 业务字段(原名) ----------------
String get id => str('id');
set id(dynamic v) => m['id'] = v;
String get questionId => str('questionId');
set questionId(dynamic v) => m['questionId'] = v;
dynamic get questionTypeId => m['questionTypeId'];
set questionTypeId(dynamic v) => m['questionTypeId'] = v;
String get questionTypeName => str('questionTypeName');
set questionTypeName(String v) => m['questionTypeName'] = v;
dynamic get questionTypeCode => m['questionTypeCode'];
bool get isObjective => boolOf('isObjective');
bool get isSelect => boolOf('isSelect');
String get stem => str('stem');
set stem(String v) => m['stem'] = v;
String get answer => str('answer');
set answer(String v) => m['answer'] = v;
String get correctAnswer => str('correctAnswer');
set correctAnswer(String v) => m['correctAnswer'] = v;
String get userAnswer => str('userAnswer');
set userAnswer(dynamic v) => m['userAnswer'] = v;
/// 部分接口字段(与 userAnswer 互为兜底)
String get myAnswer => str('myAnswer');
set myAnswer(dynamic v) => m['myAnswer'] = v;
/// Vue 端 child.userAnswer || child.myAnswer
String get effectiveAnswer {
final u = m['userAnswer'];
return u != null && '$u'.isNotEmpty ? '$u' : myAnswer;
}
int? get answerStatus => intOf('answerStatus');
set answerStatus(dynamic v) => m['answerStatus'] = v;
/// 解析(字符串或 {analysis: '...'})
dynamic get answerAnalysisRaw => m['answerAnalysis'];
set answerAnalysisRaw(dynamic v) => m['answerAnalysis'] = v;
String get answerAnalysisText {
final v = m['answerAnalysis'];
if (v is Map) return '${v['analysis'] ?? ''}';
return v == null ? '' : '$v';
}
Map<String, dynamic>? get answerAnalysisVideo {
final v = m['answerAnalysisVideo'];
return v is Map ? Map<String, dynamic>.from(v) : null;
}
List<Map<String, dynamic>> get audios => mapList('audios');
List<Map<String, dynamic>> get videos => mapList('videos');
int get planTimeSecond => intOf('planTimeSecond') ?? 0;
set planTimeSecond(dynamic v) => m['planTimeSecond'] = v;
/// 运行时:本地剩余秒缓存
int? get newPlanTimeSecond => intOf('NewPlanTimeSecond');
set newPlanTimeSecond(int? v) => m['NewPlanTimeSecond'] = v;
/// 运行时:本题建议时长是否已完成
bool get isFinishPlanTime => boolOf('ISFinishPlanTime');
set isFinishPlanTime(bool v) => m['ISFinishPlanTime'] = v;
int get answerTimeSecond => intOf('answerTimeSecond') ?? 0;
dynamic get score => m['score'];
dynamic get userScore => m['userScore'];
bool get isCollect => boolOf('isCollect');
set isCollect(bool v) => m['isCollect'] = v;
dynamic get collectId => m['collectId'];
set collectId(dynamic v) => m['collectId'] = v;
bool get isWrongCollect => boolOf('isWrongCollect');
set isWrongCollect(bool v) => m['isWrongCollect'] = v;
dynamic get wrongCollectId => m['wrongCollectId'];
set wrongCollectId(dynamic v) => m['wrongCollectId'] = v;
bool get hasChild => boolOf('hasChild');
List<QuestionModel> get childs =>
mapList('childs').map(QuestionModel.new).toList();
List<dynamic> get indexes =>
m['indexes'] is List ? m['indexes'] as List : const [];
/// 笔记图片({id,url,thumbnailUrl} 等)
List<Map<String, dynamic>> get notes => mapList('notes');
set notes(List<dynamic> v) => m['notes'] = v;
String get source => str('source');
String? get wrongReason =>
m['wrongReason'] == null ? null : '${m['wrongReason']}';
String? get wrongAdvice =>
m['wrongAdvice'] == null ? null : '${m['wrongAdvice']}';
bool get isUploadNotes => boolOf('isUploadNotes');
/// 加载时注入:0 输入框 / 1 图片作答
int get isFillblankItemUpload => intOf('iSFilllankItemUpload') ?? 0;
set isFillblankItemUpload(dynamic v) => m['iSFilllankItemUpload'] = v;
dynamic get baseSubjectId => m['baseSubjectId'];
set baseSubjectId(dynamic v) => m['baseSubjectId'] = v;
int? get sort => intOf('sort');
set sort(dynamic v) => m['sort'] = v;
int? get wrongSort => intOf('wrongSort');
set wrongSort(dynamic v) => m['wrongSort'] = v;
/// appendExamQuestionChildOptionStatus 注入
bool get hasOptionChildrenQuestion => boolOf('hasOptionChildrenQuestion');
set hasOptionChildrenQuestion(bool v) =>
m['hasOptionChildrenQuestion'] = v;
List<QuestionModel> get subQuestions =>
mapList('subQuestions').map(QuestionModel.new).toList();
List<QuestionModel> get bkTagMetaList =>
mapList('bkTagMetaList').map(QuestionModel.new).toList();
/// 选项 [{optionLabel, options}]
List<Map<String, String>> get options {
final v = m['options'];
if (v is! List) return const [];
return v.whereType<Map>().map((e) {
return {
'optionLabel': '${e['optionLabel'] ?? ''}',
'options': '${e['options'] ?? ''}',
};
}).toList();
}
String get questionTypes => str('questionTypes');
set questionTypes(String v) => m['questionTypes'] = v;
String get analysis => str('analysis');
set analysis(String v) => m['analysis'] = v;
/// 解析时保留的原始对象
Map<String, dynamic>? get raw {
final v = m['raw'];
return v is Map ? Map<String, dynamic>.from(v) : null;
}
/// 运行时:进入本题时间(本地 ISO)
String? get startTime => m['startTime'] as String?;
set startTime(String? v) => m['startTime'] = v;
/// 运行时:离开本题时间(本地 ISO)
String? get endTime => m['endTime'] as String?;
set endTime(String? v) => m['endTime'] = v;
/// 子题号(如 "1.1",subQuestions 元素自带)
dynamic get index => m['index'];
/// 运行时:列表下标
int? get currentIndex => intOf('currentIndex');
set currentIndex(int v) => m['currentIndex'] = v;
// ---------------- 便捷方法 ----------------
QuestionModel? childAt(int index) {
final list = mapList('childs');
if (index < 0 || index >= list.length) return null;
return QuestionModel(list[index]);
}
QuestionModel? subAt(int index) {
final list = mapList('subQuestions');
if (index < 0 || index >= list.length) return null;
return QuestionModel(list[index]);
}
/// 重置本题及其子题的 startTime(对应 Vue resetCurrentQuestionStartTime)
void resetStartTime(String isoNow) {
m['startTime'] = isoNow;
for (final key in ['subQuestions', 'childs']) {
final v = m[key];
if (v is List) {
for (final c in v) {
if (c is Map) c['startTime'] = isoNow;
}
}
}
}
}
/// 选项取值辅助
String optionLabelOf(Map<String, String> option) => option['optionLabel'] ?? '';
String optionContentOf(Map<String, String> option) => option['options'] ?? '';
@@ -0,0 +1,462 @@
/// ============================================================
/// 【AnswerCardPopup】答题卡弹层
/// (1:1 迁移 Vue components/AnswerCardPopup/AnswerCardPopup.vue)
///
/// statusMode:
/// answer 答题中:未答 / 已答 /(扩展)时长不足 / 待补笔记,底部双按钮
/// result 查看结果:答对 / 答错 / 未答,无底部按钮
///
/// 数据:buildExamAnswerCardQuestions 拍平 → 按 sort 排序 →
/// 相邻 questionTypeName 分组。
/// 事件:onSelect(questionMap) 选择题目(同时关闭);
/// onCancel 继续答题/关闭;onSubmit 交卷。
/// ============================================================
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/utils/question/exam_answer_card.dart';
import 'exercise_styles.dart';
/// 答题卡状态码
class AnswerCardStatus {
AnswerCardStatus._();
static const int unanswered = 0;
static const int answered = 1;
static const int correct = 2;
static const int wrong = 3;
static const int planTimeNotEnough = 20;
static const int pendingNotes = 21;
}
class AnswerCardPopup extends StatelessWidget {
const AnswerCardPopup({
super.key,
required this.show,
required this.questions,
this.isExamAnswerCard = true,
this.statusMode = 'answer',
this.enableExtendedAnswerStatus = false,
this.onCancel,
this.onSubmit,
this.onSelect,
});
final bool show;
/// QuestionModel.m 原始题目 Map 列表(同一引用即可,内部只读取/拷贝)
final List<Map<String, dynamic>> questions;
final bool isExamAnswerCard;
/// answer / result
final String statusMode;
final bool enableExtendedAnswerStatus;
final VoidCallback? onCancel;
final VoidCallback? onSubmit;
final ValueChanged<Map<String, dynamic>>? onSelect;
bool get _extended =>
statusMode == 'answer' && enableExtendedAnswerStatus;
// ---------------- 状态判定(1:1 移植 Vue) ----------------
String _normalizeAnswer(dynamic value) =>
'$value'.replaceAll(RegExp(r'<[^>]+>'), '').replaceAll(RegExp(r'\s+'), '').toUpperCase();
bool _hasUploadedNotes(dynamic notes) {
if (notes is List) return notes.isNotEmpty;
return hasValue(notes);
}
bool _hasFinishedPlanTime(Map<String, dynamic> q) {
final planTimeSecond = _num(q['planTimeSecond']);
if (planTimeSecond <= 0) return true;
if (q['ISFinishPlanTime'] == true) return true;
final answerTimeSecond = _num(q['answerTimeSecond']);
return answerTimeSecond > planTimeSecond;
}
int _questionStatus(Map<String, dynamic> q) {
final fillBlankAnswered = isFillBlankFullyAnswered(q);
final answered = (fillBlankAnswered == null || !fillBlankAnswered)
? hasValue(q['userAnswer'])
: fillBlankAnswered;
if (statusMode == 'answer') {
if (!answered) return AnswerCardStatus.unanswered;
if (_extended &&
_num(q['planTimeSecond']) > 0 &&
!_hasFinishedPlanTime(q)) {
return AnswerCardStatus.planTimeNotEnough;
}
if (_extended &&
q['isUploadNotes'] == true &&
!_hasUploadedNotes(q['notes'])) {
return AnswerCardStatus.pendingNotes;
}
return AnswerCardStatus.answered;
}
// result 模式
if (!answered) return AnswerCardStatus.unanswered;
final rawStatus = q['answerStatus'];
if (rawStatus != null) {
final status = _num(rawStatus, -1);
if (status == 10) return AnswerCardStatus.correct;
if (status == 11 || status == 12 || status == 1) {
return AnswerCardStatus.wrong;
}
if (status == 0) return AnswerCardStatus.unanswered;
}
final myAnswer = _normalizeAnswer(q['userAnswer']);
final correctAnswer =
_normalizeAnswer(q['correctAnswer'] ?? q['answer']);
return myAnswer == correctAnswer
? AnswerCardStatus.correct
: AnswerCardStatus.wrong;
}
/// 拍平 + 按相邻题型分组
List<Map<String, dynamic>> _groups() {
final flat = buildExamAnswerCardQuestions(
questions,
isExamAnswerCard: isExamAnswerCard,
)..sort((a, b) => _num(a['sort']).compareTo(_num(b['sort'])));
final result = <Map<String, dynamic>>[];
Map<String, dynamic>? current;
for (final q in flat) {
final typeName = '${q['questionTypeName'] ?? '题目'}';
if (current == null || current['name'] != typeName) {
current = {
'name': typeName,
'questions': <Map<String, dynamic>>[],
};
result.add(current);
}
(current['questions'] as List).add({
...q,
'status': _questionStatus(q),
});
}
return result;
}
// ---------------- 渲染 ----------------
@override
Widget build(BuildContext context) {
if (!show) return const SizedBox.shrink();
final isPad = isPadDevice(context);
final screenH = MediaQuery.sizeOf(context).height;
return Positioned.fill(
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onCancel,
child: const ColoredBox(color: Color(0x99000000)),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: screenH * 0.8,
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 14.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(
isPad
? '/StaticResource/2026-06-02/answer-sheet-bg-Pad1780382627852.png'
: '/StaticResource/2026-04-08/answer-sheet-bg.png',
),
),
fit: isPad ? BoxFit.fill : BoxFit.fill,
),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
_buildHeader(isPad),
Expanded(child: _buildBody()),
if (statusMode == 'answer') _buildFooter(),
],
),
),
),
// pad 右上关闭热区(50% 宽 × 400rpx)
if (isPad)
Positioned(
right: 0,
top: screenH * 0.04,
width: MediaQuery.sizeOf(context).width * 0.5,
height: 200.h,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onCancel,
),
),
],
),
);
}
Widget _buildHeader(bool isPad) {
return Padding(
padding: EdgeInsets.only(top: isPad ? 180.h : 95.h), // 190/360rpx
child: Column(
children: [
// 标题 "答题卡",右下角 title-bacs.png 装饰背景
Align(
alignment: Alignment.centerLeft,
child: Container(
padding: EdgeInsets.only(right: 30.w),
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(
'/StaticResource/2026-04-01/title-bacs.png'),
),
alignment: Alignment.centerRight,
fit: BoxFit.fitHeight,
),
),
child: Text(
'答题卡',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: ExerciseColors.title,
),
),
),
),
SizedBox(height: 10.h),
if (statusMode == 'answer')
_buildLegend(
isPad,
_extended
? const [
_LegendItem('未答', Color(0xFF94A5B8)),
_LegendItem('已答', Color(0xFF3D7DF5)),
_LegendItem('时长不足', Color(0xFFFF9F1A)),
_LegendItem('待补笔记', Color(0xFF8B5CF6)),
]
: const [
_LegendItem('未答', Color(0xFF94A5B8)),
_LegendItem('已答', Color(0xFF3D7DF5)),
],
extended: _extended,
)
else
_buildLegend(
isPad,
const [
_LegendItem('答对', Color(0xFF00CC96)),
_LegendItem('答错', Color(0xFFFF4D4D)),
_LegendItem('未答', Color(0xFF94A5B8)),
],
extended: false,
),
],
),
);
}
Widget _buildLegend(
bool isPad,
List<_LegendItem> items, {
required bool extended,
}) {
return Container(
width: extended ? 320.w : 150.w,
padding: EdgeInsets.all(5.w),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x05FFFFFF), Color(0x33FFFFFF)],
),
border: Border.all(color: const Color(0x4DFFFFFF), width: 1.w),
borderRadius: BorderRadius.circular(16.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (final item in items)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6.w,
height: 6.w,
decoration: BoxDecoration(
color: item.color,
shape: BoxShape.circle,
),
),
SizedBox(width: 5.w),
Text(
item.label,
style:
TextStyle(fontSize: 12.sp, color: ExerciseColors.subtitle),
),
],
),
],
),
);
}
Widget _buildBody() {
final groups = _groups();
return Padding(
padding: EdgeInsets.only(top: 20.h, bottom: 20.h),
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: groups.length,
separatorBuilder: (_, _) => SizedBox(height: 20.h),
itemBuilder: (context, i) {
final g = groups[i];
final qs = (g['questions'] as List).cast<Map<String, dynamic>>();
return Container(
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(bottom: 12.h),
child: Text(
g['name'] as String,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: ExerciseColors.subtitle,
),
),
),
Wrap(
spacing: 10.w,
runSpacing: 10.h,
children: [
for (final q in qs)
GestureDetector(
onTap: () => onSelect?.call(q),
behavior: HitTestBehavior.opaque,
child: Container(
width: 44.w, // 88rpx
height: 36.h, // 72rpx
alignment: Alignment.center,
decoration: BoxDecoration(
color: _itemBg(q['status'] as int),
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
'${q['sort']}',
style: TextStyle(
fontSize: 14.sp,
color: _itemColor(q['status'] as int),
),
),
),
),
],
),
],
),
);
},
),
);
}
Widget _buildFooter() {
return Container(
color: Colors.white,
padding: EdgeInsets.only(top: 12.h, bottom: 12.h),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: ExerciseButton(
text: '继续答题',
style: ExerciseButtonStyle.outline,
onTap: onCancel,
),
),
SizedBox(width: 10.w),
Expanded(
child: ExerciseButton(
text: '交卷',
style: ExerciseButtonStyle.blueGradient,
onTap: onSubmit,
),
),
],
),
),
);
}
Color _itemBg(int status) {
switch (status) {
case AnswerCardStatus.answered:
return const Color(0xFFF0F8FF);
case AnswerCardStatus.correct:
return const Color(0xFFE8FAF6);
case AnswerCardStatus.wrong:
return const Color(0xFFFFF0F0);
case AnswerCardStatus.planTimeNotEnough:
return const Color(0xFFFFF6E8);
case AnswerCardStatus.pendingNotes:
return const Color(0xFFF4EEFF);
default:
return const Color(0xFFF5F7FA);
}
}
Color _itemColor(int status) {
switch (status) {
case AnswerCardStatus.answered:
return Color(0xFF3D7DF5);
case AnswerCardStatus.correct:
return ExerciseColors.correct;
case AnswerCardStatus.wrong:
return const Color(0xFFFF5252);
case AnswerCardStatus.planTimeNotEnough:
return const Color(0xFFFF9F1A);
case AnswerCardStatus.pendingNotes:
return const Color(0xFF8B5CF6);
default:
return const Color(0xFF94A5B8);
}
}
}
class _LegendItem {
const _LegendItem(this.label, this.color);
final String label;
final Color color;
}
int _num(dynamic v, [int def = 0]) {
if (v is num) return v.toInt();
return int.tryParse('${v ?? ''}') ?? def;
}
@@ -0,0 +1,244 @@
/// ============================================================
/// 【ObjectiveAnswerView】客观题答案对比卡片
/// (1:1 迁移 Vue components/AnswerView.vue 的 type == 'objective' 分支;
/// 本页只使用客观题形态)
///
/// 视觉:
/// 218rpx 卡片:上行作答状态图标+文案(10 正确 / 11 错误 / 12 部分正确 /
/// 0 未作答),下行白色对比块:"正确答案:X / 你的答案:Y"
/// ============================================================
library;
import 'package:cached_network_image/cached_network_image.dart';
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/core/utils/img_url.dart';
import 'package:new_learn/core/utils/question/question_parser.dart';
import 'exercise_styles.dart';
class ObjectiveAnswerView extends StatelessWidget {
const ObjectiveAnswerView({
super.key,
required this.answerStatus,
this.userAnswer = '',
this.correctAnswer = '',
});
/// 0 未作答 / 10 正确 / 11 错误 / 12 部分正确
final dynamic answerStatus;
final String userAnswer;
final String correctAnswer;
int get _status => int.tryParse('$answerStatus') ?? 0;
bool get _isCorrect => _status == 10;
/// 客观题文字展示清洗(对标 formatOptionText),仅用于判空与纯文本展示
String _formatOptionText(dynamic ans) {
if (ans == null) return '';
String str;
if (ans is List) {
str = ans.join();
} else if (ans is Map) {
str = ans.toString();
if (str == '{}' || str == '[]') return '';
} else {
str = '$ans';
}
final trimmed = str.trim();
if (trimmed.isEmpty || trimmed == '[]' || trimmed == '{}' ||
trimmed == '""' || trimmed == "''") {
return '';
}
var result = trimmed.replaceAll(RegExp(r'<[^>]+>'), '');
result = result.replaceAll('&nbsp;', ' ');
result = result.replaceAll(RegExp(r'&[a-z]+;'), '');
result = result.replaceAll(RegExp(r'\s+'), ' ');
result = result.replaceAll(RegExp(r'^(\d+[\.\s、)\s]+|第\d+题)'), '');
result = result.replaceAll(',', '');
return result.trim();
}
/// 清洗后的答案 HTML(富文本渲染用)
String _rendered(String html) => cleanRichText(html);
bool _hasFormula(String html) =>
RegExp(r'math-formula|katex').hasMatch(html);
@override
Widget build(BuildContext context) {
final correctText = _formatOptionText(correctAnswer);
final userText = _formatOptionText(userAnswer);
final correctDisplay = correctText.isEmpty ? '无' : correctText;
final userDisplay = userText.isEmpty ? '未作答' : userText;
final statusText = _status == 10
? '回答正确'
: _status == 11
? '回答错误'
: _status == 12
? '部分正确'
: '未作答';
final accent = _isCorrect ? ExerciseColors.correct : ExerciseColors.error;
final correctHtml = _rendered(correctAnswer);
final userHtml = _rendered(userAnswer);
return Container(
height: 109.h, // 218rpx
decoration: BoxDecoration(
// CSS 外层:实色 padding-box + 180deg 渐变 border-box
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: _isCorrect
? const [Color(0x8000CC96), Color(0x1A00CC96), Color(0x8000CC96)]
: const [Color(0x80FF4C4C), Color(0x1AFF4C4C), Color(0x80FF4C4C)],
),
borderRadius: BorderRadius.circular(12.r),
),
child: Container(
margin: EdgeInsets.all(1.w),
decoration: BoxDecoration(
color: _isCorrect ? const Color(0xFFEBFFFA) : const Color(0xFFFFF0F0),
borderRadius: BorderRadius.circular(11.r),
),
child: Column(
children: [
// 第一行:状态提示
Padding(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
child: Row(
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(
_isCorrect
? '/StaticResource/2026-06-01/correct-icon1780284329572.png'
: '/StaticResource/2026-06-01/error-icon1780284292405.png',
),
width: 16.w,
height: 16.w,
),
SizedBox(width: 6.w),
Text(
statusText,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: accent,
),
),
],
),
),
// 第二行:答案对比
Expanded(
child: Container(
margin: EdgeInsets.fromLTRB(1.w, 0, 1.w, 1.w),
padding: EdgeInsets.fromLTRB(14.w, 0, 26.w, 0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10.r),
),
child: Row(
children: [
Expanded(
child: _CompareItem(
label: '正确答案:',
child: _hasFormula(correctHtml)
? RichTextContent(
html: correctHtml,
textStyle: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF333333),
fontWeight: FontWeight.bold,
),
)
: Text(
correctDisplay,
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF333333),
fontWeight: FontWeight.bold,
),
),
),
),
Expanded(
child: _CompareItem(
alignEnd: true,
label: '你的答案:',
child: _hasFormula(userHtml)
? RichTextContent(
html: userHtml,
textStyle: TextStyle(
fontSize: 14.sp,
color: _isCorrect
? ExerciseColors.correct
: (userText.isEmpty
? const Color(0xFF999999)
: ExerciseColors.error),
fontWeight: FontWeight.bold,
),
)
: Text(
userDisplay,
style: TextStyle(
fontSize: 14.sp,
color: _isCorrect
? ExerciseColors.correct
: (userText.isEmpty
? const Color(0xFF999999)
: ExerciseColors.error),
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
],
),
),
);
}
}
class _CompareItem extends StatelessWidget {
const _CompareItem({
required this.label,
required this.child,
this.alignEnd = false,
});
final String label;
final Widget child;
final bool alignEnd;
@override
Widget build(BuildContext context) {
final row = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
style: TextStyle(fontSize: 14.sp, color: ExerciseColors.subtitle),
),
),
Flexible(child: child),
],
);
return alignEnd
? Align(alignment: Alignment.centerRight, child: row)
: Align(alignment: Alignment.centerLeft, child: row);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,252 @@
/// ============================================================
/// 【ConquerPopup】攻克流程弹窗
/// (1:1 迁移 Vue components/ConquerPopup/ConquerPopup.vue)
///
/// 四种类型:
/// note 过程笔记提示
/// time 最低作答时长提示(确认键在左、取消键在右)
/// submit 确认交卷(取消键在左、确认键在右)
/// grading 题目批改中(遮罩与按钮都不能关闭;唯一按钮 emit confirm)
///
/// 事件(与 Vue emit 对齐):
/// onConfirm(action) 点确认按钮
/// onCancel() 点遮罩/取消;若 confirmAction == 'showAnswerSheet'
/// (isConfirm 单按钮或遮罩)会紧跟一次 onConfirm(action)
/// ============================================================
library;
import 'package:cached_network_image/cached_network_image.dart';
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/core/utils/img_url.dart';
import 'exercise_styles.dart';
class ConquerPopup extends StatelessWidget {
const ConquerPopup({
super.key,
required this.show,
this.type = 'note',
this.title = '',
this.subtitle = '',
this.confirmText = '确定',
this.cancelText = '取消',
this.isConfirm = false,
this.confirmAction = '',
this.onConfirm,
this.onCancel,
});
final bool show;
/// note / time / submit / grading
final String type;
final String title;
final String subtitle;
final String confirmText;
final String cancelText;
final bool isConfirm;
final String confirmAction;
final ValueChanged<String>? onConfirm;
final VoidCallback? onCancel;
static const Map<String, String> _bgMap = {
'note': '/StaticResource/2026-04-08/conquer-note-bg.png',
'time': '/StaticResource/2026-04-08/conquer-time-bg.png',
'submit': '/StaticResource/2026-04-08/conquer-submit-bg.png',
'grading': '/StaticResource/2026-04-08/conquer-grading-bg.png',
};
/// grading 遮罩点击:直接拦截不关闭
void _handleMaskCancel() {
if (type == 'grading') return;
_handleCancel();
}
/// 普通取消:关闭 + cancel + showAnswerSheet 时补发 confirm
void _handleCancel() {
onCancel?.call();
if (confirmAction == 'showAnswerSheet') {
onConfirm?.call(confirmAction);
}
}
void _handleConfirm() {
onConfirm?.call(confirmAction);
}
/// 标题/副标题可能是 HTML(v-html)
Widget _htmlOrText(
String html, {
required double fontSize,
required Color color,
FontWeight? fontWeight,
double? height,
}) {
final style = TextStyle(
fontSize: fontSize,
color: color,
fontWeight: fontWeight,
height: height,
);
if (html.contains(RegExp(r'<[a-zA-Z][^>]*>'))) {
return RichTextContent(html: html, textStyle: style);
}
return Text(html, textAlign: TextAlign.center, style: style);
}
@override
Widget build(BuildContext context) {
if (!show) return const SizedBox.shrink();
final isPad = isPadDevice(context);
final isGrading = type == 'grading';
return Positioned.fill(
child: Stack(
children: [
// mask rgba(0,0,0,.6)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleMaskCancel,
child: const ColoredBox(color: Color(0x99000000)),
),
),
Center(
child: Container(
width: isPad ? 305.w : 278.w, // 556rpx / 610rpx
constraints: BoxConstraints(minHeight: 352.5.h), // 705rpx
padding: EdgeInsets.only(
left: 27.5.w, // 55rpx
right: 25.w, // 50rpx
bottom: 30.h, // 60rpx
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15.r),
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(_bgMap[type] ?? _bgMap['note']!),
),
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(top: isPad ? 170.h : 150.h),
child: _htmlOrText(
title,
fontSize: 18.sp, // 36rpx
color: ExerciseColors.title,
fontWeight: FontWeight.w600,
height: 25.h / 18.sp,
),
),
if (isGrading) ...[
// 预计 3~5 分钟有结果
Container(
margin: EdgeInsets.only(top: 20.h),
height: 44.h, // 88rpx
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFFF2F8FF),
borderRadius: BorderRadius.circular(6.r),
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontSize: 14.sp,
color: ExerciseColors.subtitle,
),
children: [
const TextSpan(text: '预计 '),
TextSpan(
text: '3~5',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: ExerciseColors.primary,
),
),
const TextSpan(text: ' 分钟有结果'),
],
),
),
),
Padding(
padding: EdgeInsets.only(top: 15.h),
child: ExerciseButton(
text: confirmText,
style: ExerciseButtonStyle.primary,
width: 220.w, // 440rpx
onTap: _handleConfirm,
),
),
] else ...[
if (subtitle.isNotEmpty)
Padding(
padding: EdgeInsets.only(top: 10.h, left: 10.w, right: 10.w),
child: _htmlOrText(
subtitle,
fontSize: 14.sp, // 28rpx
color: ExerciseColors.subtitle,
height: 22.h / 14.sp,
),
),
Padding(
padding: EdgeInsets.only(top: 25.h),
child: isConfirm
? ExerciseButton(
text: confirmText,
style: ExerciseButtonStyle.primary,
width: double.infinity,
onTap: _handleCancel,
)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: type == 'time'
? [
// time:确认在左(白底)、取消在右(蓝底)
ExerciseButton(
text: confirmText,
style: ExerciseButtonStyle.secondary,
width: 105.w, // 210rpx
onTap: _handleConfirm,
),
ExerciseButton(
text: cancelText,
style: ExerciseButtonStyle.primary,
width: 105.w,
onTap: _handleCancel,
),
]
: [
// note / submit:取消在左、确认在右
ExerciseButton(
text: cancelText,
style: ExerciseButtonStyle.secondary,
width: 105.w,
onTap: _handleCancel,
),
ExerciseButton(
text: confirmText,
style: ExerciseButtonStyle.primary,
width: 105.w,
onTap: _handleConfirm,
),
],
),
),
],
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,136 @@
/// ============================================================
/// 【ExerciseConfirmBox】通用确认弹窗
/// (1:1 迁移 Vue classExercises/ExerciseConfirmBox.vue)
///
/// 交互点:
/// - 点遮罩 / 取消:onCancel(父级负责把 show 置 false)
/// - 点确定:onConfirm(不主动关闭,与 Vue 一致由业务决定)
/// - showCancel=false 时只显示单满宽确定按钮
/// ============================================================
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'exercise_styles.dart';
class ExerciseConfirmBox extends StatelessWidget {
const ExerciseConfirmBox({
super.key,
required this.show,
this.title = '提示',
this.content = '',
this.cancelText = '取消',
this.confirmText = '确定',
this.showCancel = true,
this.onCancel,
this.onConfirm,
});
final bool show;
final String title;
final String content;
final String cancelText;
final String confirmText;
final bool showCancel;
final VoidCallback? onCancel;
final VoidCallback? onConfirm;
@override
Widget build(BuildContext context) {
if (!show) return const SizedBox.shrink();
return Stack(
children: [
// mask rgba(0,0,0,.45),点击=cancel
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onCancel,
child: const ColoredBox(color: Color(0x73000000)),
),
),
Center(
child: Container(
width: 315.w, // 630rpx, max calc(100vw - 80rpx)
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width - 40.w,
),
padding: EdgeInsets.symmetric(vertical: 30.h, horizontal: 15.w),
decoration: BoxDecoration(
color: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(20.r),
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build('/StaticResource/2026-03-31/header-bac.png'),
),
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title.isNotEmpty)
Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: ExerciseColors.title,
),
),
if (content.isNotEmpty)
Padding(
padding: EdgeInsets.only(top: 18.h),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 263.w),
child: Text(
content,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
color: ExerciseColors.title,
height: 1.5,
),
),
),
),
Padding(
padding: EdgeInsets.only(top: 23.h),
child: showCancel
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ExerciseButton(
text: cancelText,
style: ExerciseButtonStyle.secondary,
width: 135.w,
onTap: onCancel,
),
SizedBox(width: 10.w),
ExerciseButton(
text: confirmText,
style: ExerciseButtonStyle.primary,
width: 135.w,
onTap: onConfirm,
),
],
)
: ExerciseButton(
text: confirmText,
style: ExerciseButtonStyle.primary,
width: double.infinity,
onTap: onConfirm,
),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,336 @@
/// ============================================================
/// 课堂习题模块共享样式
///
/// 集中放置 classExercises 各 widget 复用的:
/// 1. 颜色常量(对标 Vue common.scss 的 $default-color 系列)
/// 2. isPadDevice 判定(shortestSide >= 600)
/// 3. 渐变描边容器(对标 CSS padding-box/border-box 双层渐变)
/// 4. 通用胶囊按钮(对标 default-button1/2/3/4/5/6)
/// ============================================================
library;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 与 Vue 端一一对应的色板
class ExerciseColors {
ExerciseColors._();
/// 主题蓝 $default-color
static const Color primary = Color(0xFF3D7DF5);
/// $default-color-strong
static const Color primaryStrong = Color(0xFF3373FF);
/// $default-color-mid
static const Color primaryMid = Color(0xFF3C84FF);
/// $default-color-light
static const Color primaryLight = Color(0xFF8AD1FD);
/// 标题深色
static const Color title = Color(0xFF1D2B47);
/// 次级文字
static const Color subtitle = Color(0xFF687FAD);
/// 正确绿
static const Color correct = Color(0xFF00CC96);
/// 错误红
static const Color error = Color(0xFFFF4D4D);
/// 页面/卡片浅底
static const Color bg = Color(0xFFF5F8FA);
/// 选项默认底
static const Color tileBg = Color(0xFFFAFBFD);
/// 浅蓝描边
static const Color border = Color(0xFFE3ECFF);
}
/// 对标 Vue utils/util.js isPadDevice
bool isPadDevice(BuildContext context) =>
MediaQuery.sizeOf(context).shortestSide >= 600;
/// 选项 / 状态卡片的视觉态
enum ExerciseTileState {
/// 默认:浅底 + 浅蓝描边
plain,
/// 作答中选中:蓝渐变描边
active,
/// 正确:绿渐变描边
correct,
/// 错误:红渐变描边
error,
}
/// 渐变描边容器
///
/// 对标 CSS 写法:
/// `background: linear-gradient(...) padding-box,
/// linear-gradient(...) border-box; border: 2rpx solid transparent;`
/// Flutter 用「外层渐变 + 1.w 内边距 + 内层实色」实现相同观感。
class GradientBorderBox extends StatelessWidget {
const GradientBorderBox({
super.key,
required this.child,
this.state = ExerciseTileState.plain,
this.radius = 12,
this.padding,
this.margin,
this.borderWidth,
this.fillColor,
this.borderGradient,
});
final Widget child;
final ExerciseTileState state;
final double radius;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
/// 描边宽度(默认 2rpx = 1.w)
final double? borderWidth;
/// 自定义内层填充色(不传按 state 取)
final Color? fillColor;
/// 自定义描边渐变(不传按 state 取)
final Gradient? borderGradient;
Color get _fill {
if (fillColor != null) return fillColor!;
switch (state) {
case ExerciseTileState.plain:
return ExerciseColors.tileBg;
case ExerciseTileState.active:
return const Color(0xFFF0F8FF);
case ExerciseTileState.correct:
return const Color(0xFFEBFFFA);
case ExerciseTileState.error:
return const Color(0xFFFFF0F0);
}
}
Gradient? get _gradient {
if (borderGradient != null) return borderGradient;
switch (state) {
case ExerciseTileState.plain:
return null;
case ExerciseTileState.active:
// rgba(101,189,250,.1) → rgba(101,189,250,.5)
return const LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Color(0x1A65BDFF), Color(0x8065BDFF)],
);
case ExerciseTileState.correct:
return const LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Color(0x1A00CC96), Color(0x8000CC96)],
);
case ExerciseTileState.error:
return const LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Color(0x1AFF4C4C), Color(0x80FF4C4C)],
);
}
}
@override
Widget build(BuildContext context) {
final bw = borderWidth ?? 1.w;
final br = BorderRadius.circular(radius.r);
final outer = state == ExerciseTileState.plain
? BoxDecoration(
color: _fill,
borderRadius: br,
border: Border.all(color: ExerciseColors.border, width: bw),
)
: BoxDecoration(
gradient: _gradient,
borderRadius: br,
);
return Container(
margin: margin,
decoration: outer,
child: Container(
margin: state == ExerciseTileState.plain ? null : EdgeInsets.all(bw),
padding: padding,
decoration: BoxDecoration(
color: state == ExerciseTileState.plain ? null : _fill,
borderRadius: BorderRadius.circular(
state == ExerciseTileState.plain ? radius.r : radius.r - bw,
),
),
child: child,
),
);
}
}
/// 圆形选项标号的背景(对标 .options-li-Head 激活态径向渐变)
Gradient? headGradientOf(ExerciseTileState state) {
switch (state) {
case ExerciseTileState.plain:
return null;
case ExerciseTileState.active:
// radial-gradient(110.77% 110% at 50.93% 90%, #74CAFF, transparent), #3D7DF5
return const RadialGradient(
center: Alignment(0.0186, 0.8),
radius: 1.1,
colors: [Color(0xFF74CAFF), ExerciseColors.primary],
);
case ExerciseTileState.correct:
return const RadialGradient(
center: Alignment(0.0262, 0.7894),
radius: 1.1,
colors: [Color(0xFF52E3A2), ExerciseColors.correct],
);
case ExerciseTileState.error:
return const RadialGradient(
center: Alignment(0.0262, 0.7894),
radius: 1.1,
colors: [Color(0xFFFF877C), Color(0xFFFF4C4C)],
);
}
}
/// 胶囊按钮样式(对标 default-button1/2/3/4)
enum ExerciseButtonStyle {
/// default-button1:左→右蓝色渐变,交卷/提交
blueGradient,
/// default-button3:下→上浅蓝渐变,确定/确认
primary,
/// default-button2:白底蓝字带投影,弹窗次级按钮
secondary,
/// default-button4:白底蓝字蓝描边,继续答题
outline,
}
/// 通用胶囊按钮(92rpx 高、100rpx 圆角)
class ExerciseButton extends StatelessWidget {
const ExerciseButton({
super.key,
required this.text,
required this.onTap,
this.style = ExerciseButtonStyle.primary,
this.width,
this.height,
this.fontSize,
this.enabled = true,
});
final String text;
final VoidCallback? onTap;
final ExerciseButtonStyle style;
final double? width;
final double? height;
final double? fontSize;
final bool enabled;
@override
Widget build(BuildContext context) {
final h = height ?? 46.h;
final fs = fontSize ?? 16.sp;
Gradient? gradient;
Color? color;
Color textColor;
Border? border;
List<BoxShadow> shadows = const [];
switch (style) {
case ExerciseButtonStyle.blueGradient:
// linear-gradient(90deg, #3373FF 0%, #3D7DF5 100%)
gradient = const LinearGradient(
colors: [ExerciseColors.primaryStrong, ExerciseColors.primary],
);
textColor = Colors.white;
shadows = [
BoxShadow(
color: const Color(0x4D3D7DF5),
offset: Offset(0, 7.5.h),
blurRadius: 15.h,
),
];
break;
case ExerciseButtonStyle.primary:
// linear-gradient(360deg, #8AD1FD -11.22%, #3C84FF 60.2%, #3D7DF5 100%)
gradient = const LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
ExerciseColors.primaryLight,
ExerciseColors.primaryMid,
ExerciseColors.primary,
],
stops: [0, 0.602, 1],
);
textColor = Colors.white;
shadows = [
BoxShadow(
color: const Color(0x143D7DF5),
offset: Offset(0, 7.5.h),
blurRadius: 5.65.h,
),
];
break;
case ExerciseButtonStyle.secondary:
color = const Color(0xFFFDFFFF);
textColor = ExerciseColors.primary;
shadows = [
BoxShadow(
color: const Color(0x143D7DF5),
offset: Offset(0, 7.5.h),
blurRadius: 5.65.h,
),
];
break;
case ExerciseButtonStyle.outline:
color = Colors.white;
textColor = ExerciseColors.primary;
border = Border.all(color: ExerciseColors.primary, width: 1.w);
break;
}
return Opacity(
opacity: enabled ? 1 : 0.5,
child: GestureDetector(
onTap: enabled ? onTap : null,
behavior: HitTestBehavior.opaque,
child: Container(
width: width,
height: h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
gradient: gradient,
borderRadius: BorderRadius.circular(50.r),
border: border,
boxShadow: shadows,
),
child: Text(
text,
style: TextStyle(
fontSize: fs,
color: textColor,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
}
@@ -0,0 +1,280 @@
/// ============================================================
/// 【FeedbackPopup】题目反馈弹层
/// (1:1 迁移 Vue components/FeedbackPopup/FeedbackPopup.vue,
/// BottomPopup → showModalBottomSheet)
///
/// 用法(与 Vue defineExpose 对齐):
/// `final key = GlobalKey<FeedbackPopupState>();`
/// `FeedbackPopup(key: key);`
/// `key.currentState!.open(id, questionId);`
///
/// 校验顺序:反馈内容 → 反馈类型;成功后自动关闭并重置。
/// ============================================================
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
import 'package:new_learn/core/components/center_toast.dart';
import 'package:new_learn/core/components/upload.dart';
import 'exercise_styles.dart';
class _FeedbackOption {
const _FeedbackOption(this.label, this.value);
final String label;
final int value;
}
const List<_FeedbackOption> _kOptions = [
_FeedbackOption('题干错误', 1),
_FeedbackOption(' 解析看不懂', 2),
_FeedbackOption('答案错误', 3),
_FeedbackOption('选项错误', 4),
_FeedbackOption('批阅错误', 5),
_FeedbackOption('其他', 0),
];
class FeedbackPopup extends StatefulWidget {
const FeedbackPopup({super.key});
@override
State<FeedbackPopup> createState() => FeedbackPopupState();
}
class FeedbackPopupState extends State<FeedbackPopup> {
/// 打开反馈弹层
void open(dynamic id, dynamic questionId) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _FeedbackSheet(id: id, questionId: questionId),
);
}
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
class _FeedbackSheet extends StatefulWidget {
const _FeedbackSheet({required this.id, required this.questionId});
final dynamic id;
final dynamic questionId;
@override
State<_FeedbackSheet> createState() => _FeedbackSheetState();
}
class _FeedbackSheetState extends State<_FeedbackSheet> {
final Set<int> _selected = {};
final TextEditingController _contentController = TextEditingController();
List<UploadItem> _imageList = const [];
bool _submitting = false;
@override
void dispose() {
_contentController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_contentController.text.isEmpty) {
CenterToast.show(context, '请输入反馈内容');
return;
}
if (_selected.isEmpty) {
CenterToast.show(context, '请选择反馈类型');
return;
}
setState(() => _submitting = true);
// 提交中遮罩(对标 uni.showLoading),压在底部弹层之上
unawaited(showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black38,
builder: (_) => const Center(child: CircularProgressIndicator()),
));
final payload = {
'id': widget.id,
'questionId': widget.questionId,
'feedbackTypes': _selected.toList(),
'description': _contentController.text,
'images': _imageList.map((e) => e.url).toList(),
};
try {
final res = await ClassExercisesApi.instance.feedback(payload);
if (!mounted) return;
Navigator.of(context).pop(); // 关闭 loading
if (res != null && res != false) {
CenterToast.show(context, '反馈成功');
Navigator.of(context).pop(); // 关闭反馈弹层
} else {
CenterToast.show(context, '反馈失败,请检查网络');
}
} catch (_) {
if (!mounted) return;
Navigator.of(context).pop(); // 关闭 loading
CenterToast.show(context, '反馈失败,请检查网络');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(10)),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
Flexible(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 14.w),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildOptionsGrid(),
Padding(
padding: EdgeInsets.symmetric(vertical: 20.h),
child: _buildTextarea(),
),
Upload(
title: '上传图片',
maxCount: 5,
type: UploadType.feedback,
initialImageList: _imageList,
onChanged: (list) =>
setState(() => _imageList = list),
),
Padding(
padding: EdgeInsets.only(top: 30.h),
child: ExerciseButton(
text: '提交反馈',
style: ExerciseButtonStyle.blueGradient,
width: double.infinity,
enabled: !_submitting,
onTap: _submit,
),
),
SizedBox(height: 20.h),
],
),
),
),
],
),
),
),
);
}
Widget _buildHeader() {
return Container(
height: 60.h, // 120rpx
padding: EdgeInsets.symmetric(horizontal: 14.w),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE1EEFF), Colors.white],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(width: 24.w),
Text(
'题目反馈',
style: TextStyle(fontSize: 16.sp, color: ExerciseColors.title),
),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
behavior: HitTestBehavior.opaque,
child: Icon(Icons.close, size: 22.w, color: ExerciseColors.title),
),
],
),
);
}
Widget _buildOptionsGrid() {
final chipW = (1.sw - 14.w * 2 - 16.w * 2) / 3;
return Wrap(
spacing: 16.w, // column-gap 32rpx
runSpacing: 10.h, // row-gap 20rpx
children: [
for (final opt in _kOptions)
GestureDetector(
onTap: () => setState(() {
if (!_selected.remove(opt.value)) _selected.add(opt.value);
}),
behavior: HitTestBehavior.opaque,
child: Container(
width: chipW,
padding: EdgeInsets.only(top: 11.w, bottom: 13.5.h),
alignment: Alignment.center,
decoration: BoxDecoration(
color: _selected.contains(opt.value)
? const Color(0xFF6195F6)
: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
opt.label,
style: TextStyle(
fontSize: 14.sp,
color: _selected.contains(opt.value)
? Colors.white
: const Color(0xFF687FAD),
),
),
),
),
],
);
}
Widget _buildTextarea() {
return TextField(
controller: _contentController,
maxLines: 4,
minLines: 4,
maxLength: 500,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: '请描述您的反馈...',
hintStyle: TextStyle(fontSize: 14.sp, color: const Color(0xFFB3C2E0)),
counterStyle: TextStyle(fontSize: 12.sp, color: const Color(0xFFB3C2E0)),
filled: true,
fillColor: const Color(0xFFFAFBFD),
contentPadding: EdgeInsets.all(12.w),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(color: ExerciseColors.border, width: 1.w),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide:
BorderSide(color: ExerciseColors.primary, width: 1.w),
),
),
style: TextStyle(fontSize: 14.sp, color: ExerciseColors.title),
);
}
}
@@ -0,0 +1,278 @@
/// ============================================================
/// 【ObjOptions】客观题选项列表(1:1 迁移 Vue components/objOptions)
///
/// 交互点(与 Vue 一致):
/// - props: options / answerData{correctAnswer,myAnswer} / answer /
/// mode('edit'|'view') / questionType
/// - edit:点击选项 emit change({...item, userAnswer: hxdb HTML})
/// duoxuanti 多选解析已有答案后 toggle,按字母排序
/// panduanti 取 optionLabel 或选项纯文本
/// - view:不响应点击;正确项绿、选错项红
/// - panduanti 标号位渲染本地正确/错误图标
/// ============================================================
library;
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/core/utils/question/question_parser.dart';
import 'exercise_styles.dart';
enum ObjOptionMode { edit, view }
/// 对标 Vue answerData: { correctAnswer, myAnswer }
class ObjOptionAnswerData {
const ObjOptionAnswerData({
required this.correctAnswer,
required this.myAnswer,
});
final String correctAnswer;
final String myAnswer;
}
/// change 事件载荷:{...option, userAnswer: String}
typedef ObjOptionChanged = void Function(Map<String, dynamic> payload);
class ObjOptions extends StatelessWidget {
const ObjOptions({
super.key,
required this.options,
this.answerData,
this.answer = '',
this.mode = ObjOptionMode.view,
this.questionType = '',
this.onChanged,
});
/// [{optionLabel, options}]
final List<Map<String, String>> options;
/// view 模式答案对比数据;edit 模式通常不传
final ObjOptionAnswerData? answerData;
/// edit 模式当前 userAnswer
final String answer;
final ObjOptionMode mode;
/// data.questionTypes,如 danxuanti / duoxuanti / panduanti
final String questionType;
final ObjOptionChanged? onChanged;
bool get _isPanDuan => questionType == 'panduanti';
/// 与 correctAnswer 一致的富文本外壳,内容为纯文本
String _buildHxdbAnswerHtml(String plain) {
if (plain.isEmpty) return '';
final text = plain.trim();
if (text.isEmpty) return '';
return '<div class="hxdb-answer" name="hxdbAnswer">'
'<p data-pm-slice="1 1 []">'
'<span style="font-family: 宋体;"></span>$text</p></div>';
}
/// 多选:从已有答案解析出选项字母(支持 hxdb HTML 或 A,B,D)
List<String> _parseMultiSelectedLabels(
String answerStr,
List<String> validLabels,
) {
final plain = stripAnswerPlain(answerStr);
if (plain.isEmpty) return const [];
final valid = validLabels.where((e) => e.isNotEmpty).toSet();
if (plain.contains(',')) {
return plain
.split(',')
.map((s) => s.trim())
.where(valid.contains)
.toList();
}
return plain.split('').where(valid.contains).toList();
}
void _onClick(Map<String, String> item) {
if (mode != ObjOptionMode.edit) return;
final changed = onChanged;
if (changed == null) return;
final validLabels =
options.map((o) => o['optionLabel'] ?? '').where((e) => e.isNotEmpty).toList();
final label = item['optionLabel'] ?? '';
if (questionType == 'duoxuanti') {
final myAnswer = answerData != null &&
answerData!.myAnswer.trim().isNotEmpty
? answerData!.myAnswer
: answer;
final set = _parseMultiSelectedLabels(myAnswer, validLabels).toSet();
if (label.isNotEmpty && set.contains(label)) {
set.remove(label);
} else if (label.isNotEmpty) {
set.add(label);
}
final sorted = (set.toList()..sort()).join();
changed({...item, 'userAnswer': _buildHxdbAnswerHtml(sorted)});
return;
}
final plain = _isPanDuan
? (label.isNotEmpty ? label : stripAnswerPlain(item['options']))
: label;
changed({...item, 'userAnswer': _buildHxdbAnswerHtml(plain)});
}
/// 选项内容富文本(td 转行内容器并清理头部空白)
String _formatOption(String html) {
if (html.isEmpty) return '';
var res = cleanRichText(html);
res = res.replaceAll(
RegExp(r'<td\b[^>]*>', caseSensitive: false),
'<span>',
);
res = res.replaceAll(
RegExp(r'</td>', caseSensitive: false),
'</span>',
);
res = res.replaceAll(RegExp(r'^(?:&nbsp;|\s)+', caseSensitive: false), '');
return res;
}
bool _isTrueOption(Map<String, String> item) {
final label = item['optionLabel'] ?? '';
final opts = item['options'] ?? '';
return label == '正确' || label == '✓' || label == 'A' || opts.contains('正确');
}
ExerciseTileState _stateOf(Map<String, String> item) {
final label = item['optionLabel'] ?? '';
if (label.isEmpty) return ExerciseTileState.plain;
var correctStr = '';
var myStr = '';
if (answerData != null) {
correctStr = answerData!.correctAnswer
.replaceAll(RegExp(r'<[^>]+>'), '')
.trim();
myStr = answerData!.myAnswer
.replaceAll(RegExp(r'<[^>]+>'), '')
.trim();
} else {
correctStr = answer.replaceAll(RegExp(r'<[^>]+>'), '').trim();
if (mode == ObjOptionMode.edit) {
// 兼容老代码:没传 answerData 时把 answer 当作选中的高亮
myStr = correctStr;
}
}
var isItemMine = myStr.contains(label);
var isItemCorrect = correctStr.contains(label);
if (_isPanDuan) {
final opts = item['options'] ?? '';
if (label == '正确' || opts.contains('正确')) {
isItemCorrect = ['正确', '✓', 'T', 'true', '对'].any(correctStr.contains);
isItemMine = ['正确', '✓', 'T', 'true', '对'].any(myStr.contains);
} else if (label == '错误' || opts.contains('错误') || opts.contains('错')) {
isItemCorrect = ['错误', '×', 'F', 'false', '错'].any(correctStr.contains);
isItemMine = ['错误', '×', 'F', 'false', '错'].any(myStr.contains);
}
}
if (mode == ObjOptionMode.edit) {
return isItemMine ? ExerciseTileState.active : ExerciseTileState.plain;
}
if (isItemCorrect) return ExerciseTileState.correct;
if (isItemMine && !isItemCorrect) return ExerciseTileState.error;
return ExerciseTileState.plain;
}
String _panDuanIcon(Map<String, String> item, ExerciseTileState state) {
final isActive = state != ExerciseTileState.plain;
if (_isTrueOption(item)) {
return isActive
? 'assets/img/my/correct-active-icon.png'
: 'assets/img/my/correct-inactive-icon.png';
}
return isActive
? 'assets/img/my/wrong-active-icon.png'
: 'assets/img/my/wrong-inactive-icon.png';
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < options.length; i++)
Padding(
padding: EdgeInsets.only(bottom: i == options.length - 1 ? 0 : 14.h),
child: _buildItem(options[i]),
),
],
);
}
Widget _buildItem(Map<String, String> item) {
final state = _stateOf(item);
return GestureDetector(
onTap: () => _onClick(item),
behavior: HitTestBehavior.opaque,
child: GradientBorderBox(
state: state,
radius: 12,
padding: EdgeInsets.all(12.w), // 24rpx
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildHead(item, state),
SizedBox(width: 14.w), // 76 标号 + 28 间距 = 104rpx
Expanded(
child: RichTextContent(
html: _formatOption(item['options'] ?? ''),
textStyle: TextStyle(
fontSize: 15.sp,
height: 1.5,
color: ExerciseColors.title,
),
),
),
],
),
),
);
}
Widget _buildHead(Map<String, String> item, ExerciseTileState state) {
final label = item['optionLabel'] ?? '';
final active = state != ExerciseTileState.plain;
return Container(
width: 38.w, // 76rpx
height: 38.w,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active ? null : Colors.white,
gradient: headGradientOf(state),
border: active
? null
: Border.all(color: ExerciseColors.border, width: 1.w),
),
clipBehavior: Clip.antiAlias,
child: _isPanDuan
? Image.asset(
_panDuanIcon(item, state),
width: 20.w, // 40rpx
height: 20.w,
fit: BoxFit.contain,
)
: Text(
label,
style: TextStyle(
fontSize: 18.sp, // 36rpx
fontWeight: FontWeight.w600,
color: active ? Colors.white : ExerciseColors.title,
),
),
);
}
}
@@ -0,0 +1,227 @@
/// ============================================================
/// 【PausePopup】暂停答题弹窗
/// (1:1 迁移 Vue components/PausePopup/PausePopup.vue)
///
/// - 遮罩 rgba(0,0,0,.7),点击任意位置关闭(onClose)
/// - 组件独立秒表:每次 show=true 从 0 开始计时,HH:MM:SS(不足 1 小时隐去小时)
/// - 文案:共 {totalCount} 道题,还剩 {remainingCount} 道题待完成
/// ============================================================
library;
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'exercise_styles.dart';
class PausePopup extends StatefulWidget {
const PausePopup({
super.key,
required this.show,
this.totalCount = 20,
this.remainingCount = 0,
this.onClose,
});
final bool show;
final int totalCount;
final int remainingCount;
final VoidCallback? onClose;
@override
State<PausePopup> createState() => _PausePopupState();
}
class _PausePopupState extends State<PausePopup> {
int _seconds = 0;
Timer? _timer;
@override
void initState() {
super.initState();
_syncTimer(widget.show);
}
@override
void didUpdateWidget(covariant PausePopup oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.show != widget.show) {
_syncTimer(widget.show);
}
}
@override
void dispose() {
_stopTimer();
super.dispose();
}
void _syncTimer(bool show) {
if (show) {
_startTimer();
} else {
_stopTimer();
}
}
void _startTimer() {
_timer?.cancel();
_seconds = 0;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _seconds++);
});
}
void _stopTimer() {
_timer?.cancel();
_timer = null;
}
String _two(int v) => v < 10 ? '0$v' : '$v';
@override
Widget build(BuildContext context) {
if (!widget.show) return const SizedBox.shrink();
final isPad = isPadDevice(context);
final hours = _seconds ~/ 3600;
final showHours = hours > 0;
final minutes = (_seconds % 3600) ~/ 60;
final secs = _seconds % 60;
final unitW = isPad ? 49.w : 44.w; // 88/98rpx
final unitH = isPad ? 45.h : 40.h; // 80/90rpx
return Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onClose,
child: ColoredBox(
color: const Color(0xB3000000), // rgba(0,0,0,.7)
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Transform.translate(
offset: Offset(isPad ? -35.w : 0, 0), // margin-left: -70rpx
child: Container(
width: isPad ? 315.5.w : 250.w, // 500/631rpx
height: isPad ? 299.5.h : 249.h, // 498/599rpx
padding: EdgeInsets.only(top: isPad ? 175.h : 137.5.h),
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(
isPad
? '/StaticResource/2026-06-03/conquer-pause-bg-Pad1780466965818.png'
: '/StaticResource/2026-04-08/conquer-pause-bg.png',
),
),
fit: BoxFit.fill,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (showHours) ...[
_timeUnit(unitW, unitH, _two(hours)),
_timeSep(),
],
_timeUnit(unitW, unitH, _two(minutes)),
_timeSep(),
_timeUnit(unitW, unitH, _two(secs)),
],
),
SizedBox(height: 15.h), // margin-t-30
Text.rich(
TextSpan(
style: TextStyle(
fontSize: 13.sp, // 26rpx
color: ExerciseColors.subtitle,
),
children: [
const TextSpan(text: '共'),
TextSpan(
text: '${widget.totalCount}',
style: const TextStyle(
color: Color(0xFF1578FF),
),
),
const TextSpan(text: '道题,还剩'),
TextSpan(
text: '${widget.remainingCount}',
style: const TextStyle(
color: Color(0xFF1578FF),
),
),
const TextSpan(text: '道题待完成'),
],
),
),
],
),
),
),
SizedBox(height: 25.h), // margin-t-50
Text(
'点击任意位置继续',
style: TextStyle(
fontSize: 14.sp, // 28rpx
color: const Color(0xFFB4C2E0),
),
),
],
),
),
),
),
);
}
Widget _timeUnit(double w, double h, String text) {
return Container(
width: w,
height: h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFFF2F8FF),
borderRadius: BorderRadius.circular(6.r),
boxShadow: [
BoxShadow(
color: const Color(0x1A1578FF),
offset: Offset(0, 2.h),
blurRadius: 6.h,
),
],
),
child: Text(
text,
style: TextStyle(
fontSize: 20.sp, // 40rpx
fontWeight: FontWeight.w600,
color: const Color(0xFF1578FF),
),
),
);
}
Widget _timeSep() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 10.w), // margin-rl-10 + 视觉间距
child: Text(
':',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1578FF),
),
),
);
}
}
@@ -0,0 +1,132 @@
/// ============================================================
/// 【QuestionPlanCountDown】单题建议作答时长倒计时
/// (1:1 迁移 Vue classExercises/QuestionPlanCountDown.vue,
/// 底层由 uview up-count-down 换为 Flutter Timer.periodic)
///
/// 对外方法(`GlobalKey<QuestionPlanCountDownState>` 调用):
/// start / pause / reset / getRemainTime(毫秒)
/// autoStart:initState 后自动开始;到点只触发一次 onFinish(key)。
/// ============================================================
library;
import 'dart:async';
import 'package:flutter/material.dart';
class QuestionPlanCountDown extends StatefulWidget {
const QuestionPlanCountDown({
super.key,
this.questionKey,
required this.timeMs,
this.format = 'mm:ss',
this.onFinish,
this.textStyle,
});
final Object? questionKey;
/// 剩余总时长(毫秒)
final int timeMs;
/// 'mm:ss'(本页另有 HH:mm:ss 的动态格式,按是否含 HH 切换)
final String format;
final ValueChanged<Object?>? onFinish;
final TextStyle? textStyle;
@override
State<QuestionPlanCountDown> createState() => QuestionPlanCountDownState();
}
class QuestionPlanCountDownState extends State<QuestionPlanCountDown> {
Timer? _timer;
late int _remainMs;
bool _finished = false;
@override
void initState() {
super.initState();
_remainMs = widget.timeMs;
// autoStart
start();
}
@override
void didUpdateWidget(covariant QuestionPlanCountDown oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.timeMs != widget.timeMs) {
reset();
start();
}
}
@override
void dispose() {
_timer?.cancel();
_timer = null;
super.dispose();
}
void start() {
if (_timer != null) return;
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) {
if (!mounted) return;
_remainMs -= 200;
if (_remainMs <= 0) {
_remainMs = 0;
pause();
if (!_finished) {
_finished = true;
widget.onFinish?.call(widget.questionKey);
}
}
setState(() {});
});
}
void pause() {
_timer?.cancel();
_timer = null;
}
/// 重置回初始时长并暂停(与 uview reset 行为一致,调用方需再 start)
void reset() {
pause();
_remainMs = widget.timeMs;
_finished = false;
if (mounted) setState(() {});
}
/// 剩余毫秒
int getRemainTime() => _remainMs < 0 ? 0 : _remainMs;
String _two(int v) => v < 10 ? '0$v' : '$v';
String get _display {
// 展示向上取整:刚开始显示完整秒
final seconds = (_remainMs / 1000).ceil().clamp(0, 1 << 31);
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
final s = seconds % 60;
if (widget.format.contains('HH')) {
return '${_two(h)}:${_two(m)}:${_two(s)}';
}
return '${_two(m)}:${_two(s)}';
}
@override
Widget build(BuildContext context) {
// 字体的 ascent/descent 度量让文字视觉偏下,整体上移 2px 校正
return Transform.translate(
offset: const Offset(0, -2),
child: Text(
_display,
style: widget.textStyle ??
const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
}
@@ -0,0 +1,885 @@
/// ============================================================
/// 【ReportPage】攻克报告页
/// 1:1 对标 Vue pages/conquerPackage/report/report.vue
///
/// 路由 extra:
/// - recordId 学习记录 id(同时用于 GetReport/{id})
/// - packId 攻克包 id(跳解析页时作为 totalPackId)
/// - from 来源(submit=交卷后进入:隐藏返回、显示小房子)
/// ============================================================
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/utils/question/exam_answer_card.dart';
import 'package:new_learn/core/utils/question/question_constant.dart';
import 'package:new_learn/core/utils/question/question_parser.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart';
import 'package:new_learn/features/conquerPackage/report/widgets/conquer_poster_popup.dart';
import 'package:new_learn/features/conquerPackage/report/widgets/guide_modal.dart';
import 'package:new_learn/features/conquerPackage/report/widgets/semicircle_progress.dart';
class ReportPage extends ConsumerStatefulWidget {
const ReportPage({super.key, this.extra});
final Map<String, dynamic>? extra;
@override
ConsumerState<ReportPage> createState() => _ReportPageState();
}
class _ReportPageState extends ConsumerState<ReportPage> {
// ===================== 颜色(对标 report.vue / common.scss) =====================
static const Color _bg = Color(0xFFF5F8FA);
static const Color _title = Color(0xFF1D2B47);
static const Color _primary = Color(0xFF3D7DF5);
static const Color _subtitle = Color(0xFF687FAD);
static const Color _success = Color(0xFF00CC96);
static const Color _error = Color(0xFFFF5252);
static const Color _unanswered = Color(0xFF94A5B8);
static const Color _failText = Color(0xFFB4C2E0);
// ===================== 路由参数 =====================
String _recordId = '';
String _packId = '';
bool _isFromSubmit = false;
// ===================== 页面数据 =====================
Map<String, dynamic> _data = <String, dynamic>{};
List<Map<String, dynamic>> _answerCard = [];
bool _loading = true;
bool _showPoster = false;
bool _showGuide = false;
// 跳转防连点(对标 navigateToOnce:2 秒节流 + 原子锁 + 1200ms 兜底解锁)
bool _navigating = false;
int _lastNavigateAt = 0;
@override
void initState() {
super.initState();
final extra = widget.extra ?? const <String, dynamic>{};
_recordId = '${extra['recordId'] ?? ''}';
_packId = '${extra['packId'] ?? ''}';
_isFromSubmit = extra['from'] == 'submit';
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
// ===================== 数据加载(对标 GetSubmitReportFn) =====================
Future<void> _load() async {
setState(() => _loading = true);
try {
final res =
await ClassExercisesApi.instance.getSubmitReport(_recordId);
final user = ref.read(currentUserProvider).value;
final userName = (user?.userName.isNotEmpty ?? false)
? user!.userName
: '我';
final userAvatar = user?.avatar ?? '';
final data = Map<String, dynamic>.from(res.raw);
// 后端数值字段可能返回 String,统一规范为 num,避免 build 中 as num 崩溃
final accuracyRate =
num.tryParse('${data['accuracyRate'] ?? 0}') ?? 0;
setState(() {
_data = {
...data,
'userName': userName,
'userAvatar': userAvatar,
'accuracyRate': accuracyRate,
};
});
// questions 解析 / 答题卡构建单独兜底:单题异常不影响头部与统计
final questions = res.questions;
if (questions.isNotEmpty) {
try {
final subjectType =
getSubjectTypeById(data['baseSubjectId']) ?? 0;
final parsed = questions.map((item) {
item['iSFilllankItemUpload'] = subjectType;
return parseQuestionData(item, mergeOriginalChilds: true);
}).toList();
var questionsData = appendQuestionSort(parsed);
questionsData =
appendExamQuestionChildOptionStatus(questionsData,
data['baseSubjectId']);
final card = buildExamAnswerCard(questionsData);
setState(() => _answerCard = card);
} catch (e) {
debugPrint('GetSubmitReport build answerCard error: $e');
}
}
// status 1 已攻克 / 2 未攻克 → 弹海报
final status = _di('status');
if (status == 1 || status == 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _showPoster = true);
});
}
} catch (e) {
_toast('获取报告失败,请稍后重试');
} finally {
if (mounted) setState(() => _loading = false);
}
}
int _di(String key) => int.tryParse('${_data[key] ?? 0}') ?? 0;
num get _accuracyRate =>
num.tryParse('${_data['accuracyRate'] ?? 0}') ?? 0;
bool get _hasHandouts {
final h = _data['handouts'];
return h is List && h.isNotEmpty;
}
// 回答状态(未回答=0, 判题中=1, 正确=10, 错误=11, 部分错误=12)
Color _statusColor(dynamic status) {
final s = int.tryParse('$status') ?? 0;
if (s == 10) return _success;
if (s == 11 || s == 12 || s == 1) return _error;
return _unanswered;
}
Color _statusBg(dynamic status) {
final s = int.tryParse('$status') ?? 0;
if (s == 10) return const Color(0xFFE6FAF4);
if (s == 11 || s == 12 || s == 1) return const Color(0xFFFFF2F2);
return const Color(0xFFF5F7FA);
}
// ===================== 跳转(对标 navigateToOnce) =====================
void _navigateToOnce(String location, Map<String, dynamic> extra) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastNavigateAt < 2000) return;
if (_navigating) return;
_lastNavigateAt = now;
_navigating = true;
Future.delayed(const Duration(milliseconds: 1200), () {
_navigating = false;
});
context.push(location, extra: extra).whenComplete(() {
_navigating = false;
});
}
/// 查看讲义
void _getLectureNotes() {
final id = '${_data['id'] ?? ''}';
_navigateToOnce(
'/conquerPackage/beforeDuringClass',
{
'status': '1',
'packId': id,
'recordId': id,
'type': 'view',
},
);
}
/// 全部解析 / 点答题卡题号(带 targetSort)
void _handleShowAnswerCard([Map<String, dynamic>? question]) {
final targetSort = int.tryParse('${question?['sort'] ?? 0}') ?? 0;
_navigateToOnce(
'/conquerPackage/classExercises',
{
'courseId': '${_data['courseId'] ?? ''}',
'packId': '${_data['id'] ?? ''}',
'type': 'view',
'totalPackId': _packId,
if (targetSort > 0) 'targetSort': targetSort,
},
);
}
/// 错题解析
void _handleWrongQuestionAnalysis() {
_navigateToOnce(
'/conquerPackage/classExercises',
{
'courseId': '${_data['courseId'] ?? ''}',
'packId': '${_data['id'] ?? ''}',
'type': 'view',
'totalPackId': _packId,
'isWrong': true,
},
);
}
/// 海报关闭 → 未看过引导时延迟 500ms 弹引导
void _handlePosterClose() {
setState(() => _showPoster = false);
final isShowGuide = ref.read(currentUserProvider).value?.isShowGuide;
if (isShowGuide == false) {
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) setState(() => _showGuide = true);
});
}
}
void _toast(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(text),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
// ===================== Build =====================
@override
Widget build(BuildContext context) {
final pad = isPadDevice(context);
final correct = _di('correctQuestionCount');
final total = _di('totalQuestionCount');
final allCorrect = correct == total && total > 0;
return Scaffold(
body: Stack(
children: [
// HeaderBac 顶部背景图(固定屏幕顶部,与 classExercises 页一致)
Positioned(
top: 0,
left: 0,
right: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-05-26/my-header-bg-default1779775984375.png',
),
fit: BoxFit.fitWidth,
width: double.infinity,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
// 内容层:非定位 Column + Expanded,让滚动区严格撑满整屏
// (与 class_exercises_page 相同的已验证骨架)
SafeArea(
bottom: false,
child: Column(
children: [
_buildNavBar(),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildReportBac(pad),
_buildAnswerCard(),
// 底部固定栏留白 220rpx
SizedBox(height: 110.h),
],
),
),
),
],
),
),
_buildFooter(allCorrect: allCorrect),
ConquerPosterPopup(
show: _showPoster,
status: _di('status'),
name: '${_data['name'] ?? ''}',
accuracyRate: _accuracyRate,
overcomeTimeSecond: _di('overcomeTimeSecond'),
userName: '${_data['userName'] ?? '我'}',
userAvatar: '${_data['userAvatar'] ?? ''}',
onClose: _handlePosterClose,
),
GuideModal(
show: _showGuide,
onClose: () => setState(() => _showGuide = false),
),
if (_loading) _buildLoadingMask(),
],
),
);
}
// ---------- 加载遮罩(对标 uni.showLoading 加载中...) ----------
Widget _buildLoadingMask() {
return const Positioned.fill(
child: ColoredBox(
color: Color(0x33000000),
child: Center(
child: Card(
color: Color(0xCC000000),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
SizedBox(height: 12),
Text(
'加载中...',
style: TextStyle(color: Colors.white, fontSize: 14),
),
],
),
),
),
),
),
);
}
// ===================== 滚动内容(HeaderBac + NavBar + 卡片) =====================
// 背景图 / 滚动体 / NavBar 已在 build 的外层全屏 Stack 中分层,见 build()。
// ---------- NavBar(对标 NavBar.vue:居中标题 + 返回/小房子) ----------
// 状态栏高度由外层 SafeArea 处理,这里只绘制 44px 的导航条。
Widget _buildNavBar() {
return SizedBox(
width: double.infinity,
height: 44.h,
child: Stack(
children: [
Center(
child: Padding(
padding: EdgeInsets.only(left: 10.w),
child: Text(
'攻克报告',
maxLines: 1,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w400,
color: Colors.black,
),
),
),
),
Positioned(
left: 12.w,
top: 0,
bottom: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!_isFromSubmit)
_navIcon(
'/StaticResource/2026-04-01/return.png',
onTap: _handleBack,
),
if (_isFromSubmit)
_navIcon(
'/StaticResource/2026-06-02/homesa1780391437267.png',
onTap: () => context.go('/'),
),
],
),
),
],
),
);
}
Widget _navIcon(String path, {required VoidCallback onTap}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 2.w, vertical: 10.h),
child: CachedNetworkImage(
imageUrl: ImgUrl.build(path),
width: 24.w,
height: 24.h,
fit: BoxFit.contain,
placeholder: (_, _) => Icon(
Icons.arrow_back_ios,
size: 18.w,
color: const Color(0xCC000000),
),
errorWidget: (_, _, _) => Icon(
Icons.home_outlined,
size: 20.w,
color: const Color(0xCC000000),
),
),
),
);
}
void _handleBack() {
if (context.canPop()) {
context.pop();
} else {
context.go('/');
}
}
// ===================== report-bac:头部 + 正确率/统计白卡 =====================
Widget _buildReportBac(bool pad) {
final status = _di('status');
return Container(
margin: EdgeInsets.fromLTRB(14.w, 30.h, 14.w, 0),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFAFEFF), Color(0xFFEDF7FF)],
),
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(20),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// head
Padding(
padding: EdgeInsets.fromLTRB(
pad ? 29.w : 16.w, 15.h, pad ? 29.w : 16.w, 0),
child: SizedBox(
width: double.infinity,
child: LayoutBuilder(
builder: (_, constraints) => SizedBox(
// .report-head view { width: 55% }
width: constraints.maxWidth * 0.55,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (status == 1)
_buildHeadTips(
icon:
'/StaticResource/2026-04-07/conquer-success-icon.png',
text: '恭喜你,已攻克!',
color: _primary,
),
if (status == 2)
_buildHeadTips(
icon:
'/StaticResource/2026-04-07/conquer-fail-icon.png',
text: '本次未攻克',
color: _failText,
),
Padding(
padding: EdgeInsets.only(top: 7.5.h),
child: Text(
'${_data['name'] ?? ''}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: _title,
),
),
),
],
),
),
),
),
),
// report-main(margin-t-50)
Padding(
padding: EdgeInsets.only(top: 25.h),
child: _buildMainCard(),
),
],
),
// 右上勋章 260rpx,top -100rpx
if (status == 1 || status == 2)
Positioned(
top: -50.h,
right: pad ? 20.w : 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
status == 1
? '/StaticResource/2026-04-07/conquer-success.png'
: '/StaticResource/2026-04-07/conquer-fail.png',
),
width: 130.w,
height: 130.h,
fit: BoxFit.contain,
),
),
],
),
);
}
Widget _buildHeadTips({
required String icon,
required String text,
required Color color,
}) {
return Row(
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(icon),
width: 24.w,
height: 24.h,
fit: BoxFit.contain,
),
SizedBox(width: 10.w),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14.sp, color: color),
),
),
],
);
}
Widget _buildMainCard() {
return Container(
width: double.infinity,
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
// 半圆环(padding-b-50)
Padding(
padding: EdgeInsets.only(bottom: 25.h),
child: SemicircleProgress(
percent: _accuracyRate,
),
),
_buildStatistics(),
],
),
);
}
Widget _buildStatistics() {
return Container(
padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 20.w),
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
border: Border.all(color: const Color(0xFFE3ECFF), width: 1.w),
borderRadius: BorderRadius.circular(10.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_statItem('${_di('totalQuestionCount')}', '总计', _title),
_statItem('${_di('correctQuestionCount')}', '答对', _success),
_statItem('${_di('incorrectQuestionCount')}', '答错', _error),
_statItem('${_di('unansweredQuestionCount')}', '未答', _title),
_statItem(formatDuration(_di('overcomeTimeSecond')), '用时', _title),
],
),
);
}
Widget _statItem(String num, String label, Color color) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: Text(
num,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: color,
),
),
),
Text(
label,
style: TextStyle(fontSize: 12.sp, color: _subtitle),
),
],
);
}
// ===================== 答题卡卡 =====================
Widget _buildAnswerCard() {
return Container(
width: double.infinity,
margin: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 0),
padding: EdgeInsets.fromLTRB(14.w, 14.w, 14.w, 20.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCardHead(),
for (var i = 0; i < _answerCard.length; i++)
_buildGroup(_answerCard[i], i),
],
),
);
}
Widget _buildCardHead() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-08/conquer-card-icon.png',
),
width: 18.w,
height: 18.h,
fit: BoxFit.contain,
),
SizedBox(width: 5.w),
Container(
width: 59.5.w,
height: 24.h,
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(
'/StaticResource/2026-04-01/title-bacs.png',
),
),
fit: BoxFit.fitWidth,
alignment: Alignment.bottomCenter,
),
),
child: Text(
'答题卡',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: _title,
),
),
),
],
),
Row(
children: [
_legend(_success, '答对'),
_legend(_error, '答错'),
_legend(_unanswered, '未答'),
],
),
],
);
}
Widget _legend(Color color, String label) {
return Padding(
padding: EdgeInsets.only(left: 10.w),
child: Row(
children: [
Container(
width: 6.w,
height: 6.w,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
SizedBox(width: 5.w),
Text(
label,
style: TextStyle(fontSize: 12.sp, color: _subtitle),
),
],
),
);
}
Widget _buildGroup(Map<String, dynamic> group, int index) {
final questions = (group['questions'] as List?) ?? const [];
return Padding(
padding: EdgeInsets.only(top: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(bottom: 12.h),
child: Text(
'${group['name'] ?? ''}',
style: TextStyle(fontSize: 12.sp, color: _subtitle),
),
),
Wrap(
spacing: 10.w,
runSpacing: 10.w,
children: [
for (final q in questions)
_buildQuestionItem(Map<String, dynamic>.from(q as Map)),
],
),
],
),
);
}
Widget _buildQuestionItem(Map<String, dynamic> question) {
final status = question['status'];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _handleShowAnswerCard(question),
child: Container(
width: 44.w,
height: 36.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _statusBg(status),
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
'${question['sort'] ?? ''}',
style: TextStyle(fontSize: 14.sp, color: _statusColor(status)),
),
),
);
}
// ===================== 底部固定按钮栏 =====================
Widget _buildFooter({required bool allCorrect}) {
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w),
),
),
padding: EdgeInsets.fromLTRB(
14.w,
12.h,
14.w,
12.h + MediaQuery.of(context).padding.bottom,
),
child: Row(
children: [
if (_hasHandouts) ...[
_buildLectureEntry(),
SizedBox(width: 10.w),
],
Expanded(
flex: allCorrect
? (_hasHandouts ? 80 : 100)
: (_hasHandouts ? 40 : 48),
child: _footerButton(
text: '全部解析',
primary: false,
onTap: () => _handleShowAnswerCard(),
),
),
if (!allCorrect) ...[
SizedBox(width: 14.w),
Expanded(
flex: _hasHandouts ? 40 : 48,
child: _footerButton(
text: '错题解析',
primary: true,
onTap: _handleWrongQuestionAnalysis,
),
),
],
],
),
),
);
}
Widget _buildLectureEntry() {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _getLectureNotes,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/note.png'),
width: 24.w,
height: 24.h,
fit: BoxFit.contain,
),
SizedBox(height: 5.h),
Text(
'查看讲义',
style: TextStyle(fontSize: 10.sp, color: _title),
),
],
),
);
}
/// default-button4(白底蓝边)/ default-button1(蓝渐变 + 投影)
Widget _footerButton({
required String text,
required bool primary,
required VoidCallback onTap,
}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 46.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: primary ? null : Colors.white,
gradient: primary
? const LinearGradient(
colors: [Color(0xFF3373FF), Color(0xFF3D7DF5)],
)
: null,
borderRadius: BorderRadius.circular(50.r),
border: primary
? null
: Border.all(color: _primary, width: 1.w),
boxShadow: primary
? const [
BoxShadow(
color: Color(0x4D3D7DF5),
offset: Offset(0, 7.5),
blurRadius: 15,
),
]
: null,
),
child: Text(
text,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: primary ? Colors.white : _primary,
),
),
),
);
}
}
@@ -0,0 +1,665 @@
import 'dart:ui' as ui show ImageByteFormat;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/center_toast.dart';
import 'package:new_learn/core/services/share_service.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【ConquerPosterPopup】攻克结果海报弹窗
/// 1:1 对标 Vue components/ConquerPosterPopup/ConquerPosterPopup.vue
///
/// - status == 1 成功海报,其余失败海报
/// - 「分享海报」:RepaintBoundary 截图 → ShareService 系统分享
/// - 「保存海报」:截图 → ShareService 存系统相册
/// - 点遮罩/关闭按钮关闭
/// ============================================================
class ConquerPosterPopup extends StatefulWidget {
const ConquerPosterPopup({
super.key,
required this.show,
required this.status,
required this.name,
required this.accuracyRate,
required this.overcomeTimeSecond,
required this.userName,
required this.userAvatar,
required this.onClose,
});
final bool show;
/// 1=已攻克 其它=未攻克
final int status;
final String name;
final num accuracyRate;
final int overcomeTimeSecond;
final String userName;
final String userAvatar;
final VoidCallback onClose;
@override
State<ConquerPosterPopup> createState() => _ConquerPosterPopupState();
}
class _ConquerPosterPopupState extends State<ConquerPosterPopup> {
// 主题色(对标 themes/default.json:tColor('primary') = #3D7DF5)
static const Color _primary = Color(0xFF3D7DF5);
static const Color _title = Color(0xFF1D2B47);
static const Color _subtitle = Color(0xFF687FAD);
static const Color _userName = Color(0xFF495A7A);
static const Color _shareText = Color(0xFF6195F6);
static const Color _cardBorder = Color(0xFFE3ECFF);
final GlobalKey _boundaryKey = GlobalKey();
bool _busy = false;
int get _timeMin => widget.overcomeTimeSecond ~/ 60;
int get _timeSec => widget.overcomeTimeSecond % 60;
bool get _isSuccess => widget.status == 1;
/// 整数不显示小数尾零(对标 JS 模板插值:45.0 → 45)
String _formatNum(num v) => v % 1 == 0 ? '${v.toInt()}' : '$v';
@override
Widget build(BuildContext context) {
if (!widget.show) return const SizedBox.shrink();
return Positioned.fill(
child: Stack(
children: [
// 半透明遮罩(uview up-popup 默认 0.5 黑),点击关闭
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onClose,
child: const ColoredBox(color: Color(0x80000000)),
),
),
// 弹窗主体(内容上下居中;屏幕过矮时可滚动)
Positioned.fill(
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Center(
child: SizedBox(
width: 320.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 20.h),
// 右上角关闭按钮
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onClose,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-24/topRight-close.png',
),
width: 32.w,
height: 32.h,
fit: BoxFit.contain,
errorWidget: (_, _, _) => SizedBox(
width: 32.w,
height: 32.h,
child: const Icon(Icons.close,
color: Colors.white, size: 28),
),
),
),
),
SizedBox(height: 16.h),
_buildPoster(),
SizedBox(height: 30.h),
_buildActionButtons(),
SizedBox(height: 20.h),
],
),
),
),
),
),
);
},
),
),
),
// 生成中遮罩
if (_busy)
const Positioned.fill(
child: ColoredBox(
color: Color(0x66000000),
child: Center(
child: Card(
color: Color(0xCC000000),
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
],
),
),
),
),
),
),
],
),
);
}
// ===================== 海报主容器(截图区域) =====================
Widget _buildPoster() {
return RepaintBoundary(
key: _boundaryKey,
child: Container(
width: 320.w,
padding: EdgeInsets.all(10.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(
'/StaticResource/2026-04-24/wholeBlueBg-bg.png',
),
),
fit: BoxFit.fill,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 背景装饰星星
Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 19.h,
right: 10.w,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-24/white-star-bg.png',
),
width: 159.w,
fit: BoxFit.fitWidth,
),
),
Positioned(
top: 135.h,
right: 165.w,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-24/black-star-icon.png',
),
width: 16.w,
fit: BoxFit.fitWidth,
),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左上角 Logo
Padding(
padding: EdgeInsets.only(
left: 8.w, top: 15.h, bottom: 15.h),
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-24/topLeft-logo.png',
),
width: 70.w,
height: 17.h,
fit: BoxFit.contain,
),
),
_buildInnerCard(),
],
),
],
),
],
),
),
);
}
/// 内部卡片:上半渐变(用户+吉祥物)+ 下半白色(知识点+统计)
Widget _buildInnerCard() {
return Container(
width: 300.w,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE0F6FF), Color(0xFFD6EAFF)],
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12.r),
topRight: Radius.circular(60.r),
bottomLeft: Radius.circular(12.r),
bottomRight: Radius.circular(12.r),
),
border: Border.all(color: Colors.white, width: 1.w),
),
child: Stack(
children: [
// 125.81deg 白色高光渐变(对标 CSS 第一层 background)
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12.r),
topRight: Radius.circular(60.r),
bottomLeft: Radius.circular(12.r),
bottomRight: Radius.circular(12.r),
),
gradient: const LinearGradient(
begin: Alignment(0.54, -0.84),
end: Alignment(-0.54, 0.84),
colors: [
Color(0xCCFFFFFF),
Color(0x00FFFFFF),
],
),
),
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildCardTop(),
_buildCardBottom(),
],
),
],
),
);
}
Widget _buildCardTop() {
return Container(
height: 150.h, // min-height 300rpx
padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 0),
child: Stack(
clipBehavior: Clip.none,
children: [
// 用户信息行
Row(
children: [
Container(
width: 28.w,
height: 28.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.w),
),
child: ClipOval(
child: CachedNetworkImage(
imageUrl: widget.userAvatar,
fit: BoxFit.cover,
placeholder: (_, _) => _defaultAvatar(),
errorWidget: (_, _, _) => _defaultAvatar(),
),
),
),
SizedBox(width: 8.w),
Flexible(
child: Text(
widget.userName.isNotEmpty ? widget.userName : '我',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: _userName,
),
),
),
],
),
// 状态区:发光 + 文案 + 吉祥物
Positioned(
top: 15.h,
left: 0,
right: 0,
bottom: 0,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 53.w,
top: 81.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
_isSuccess
? '/StaticResource/2026-04-24/conquerSuccessTextBlow-icon.png'
: '/StaticResource/2026-04-24/conquerFailTextBlow-icon.png',
),
width: 69.w,
fit: BoxFit.fitWidth,
),
),
Positioned(
left: 0,
top: 24.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
_isSuccess
? '/StaticResource/2026-04-24/conquerSuccessText.png'
: '/StaticResource/2026-04-24/conquerFailText.png',
),
width: 108.w,
fit: BoxFit.fitWidth,
),
),
Positioned(
right: -5.w,
bottom: 15.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
_isSuccess
? '/StaticResource/2026-04-24/conquerSuccess-image.png'
: '/StaticResource/2026-04-24/conquerFail-image.png',
),
width: 131.w,
fit: BoxFit.fitWidth,
),
),
],
),
),
],
),
);
}
Widget _buildCardBottom() {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 14.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12.r),
bottomRight: Radius.circular(12.r),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本次攻克知识点',
style: TextStyle(fontSize: 12.sp, color: _primary),
),
SizedBox(height: 10.h),
Text(
widget.name,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w500,
color: _title,
height: 1.25,
),
),
SizedBox(height: 20.h),
_buildStatsBox(),
],
),
);
}
Widget _buildStatsBox() {
return Container(
height: 92.h,
padding: EdgeInsets.only(top: 16.h, bottom: 8.h),
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: _cardBorder, width: 0.5.w),
),
child: Row(
children: [
Expanded(child: _buildStatAccuracy()),
Expanded(child: _buildStatTime()),
],
),
);
}
Widget _buildStatAccuracy() {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatNum(widget.accuracyRate),
style: TextStyle(
fontSize: 28.sp,
color: _primary,
height: 1.0,
),
),
SizedBox(width: 5.w),
Padding(
padding: EdgeInsets.only(bottom: 3.h),
child: Text('%',
style: TextStyle(fontSize: 14.sp, color: _primary)),
),
],
),
Text('正确率', style: TextStyle(fontSize: 12.sp, color: _subtitle)),
],
);
}
Widget _buildStatTime() {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (_timeMin > 0) ...[
Text('$_timeMin',
style: TextStyle(fontSize: 28.sp, color: _primary, height: 1)),
SizedBox(width: 2.w),
Padding(
padding: EdgeInsets.only(bottom: 3.h),
child: Text('分',
style: TextStyle(fontSize: 14.sp, color: _primary)),
),
SizedBox(width: 5.w),
],
Text('$_timeSec',
style: TextStyle(fontSize: 28.sp, color: _primary, height: 1)),
SizedBox(width: 2.w),
Padding(
padding: EdgeInsets.only(bottom: 3.h),
child: Text('秒',
style: TextStyle(fontSize: 14.sp, color: _primary)),
),
],
),
Text('用时', style: TextStyle(fontSize: 12.sp, color: _subtitle)),
],
);
}
// ===================== 底部按钮 =====================
Widget _buildActionButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildButton(
text: '分享海报',
width: 115.w,
textColor: _shareText,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(50.r),
border: Border.all(color: const Color(0x6683CBFB), width: 1.w),
boxShadow: const [
BoxShadow(
color: Color(0x143D7DF5),
offset: Offset(0, 7.5),
blurRadius: 5.6,
),
],
),
onTap: _handleShare,
),
SizedBox(width: 30.w),
_buildButton(
text: '保存海报',
width: 115.w,
textColor: Colors.white,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.6422, 1.0],
colors: [Color(0xFF8AD1FD), Color(0xFF3C84FF), Color(0xFF3D7DF5)],
),
borderRadius: BorderRadius.circular(50.r),
boxShadow: const [
BoxShadow(
color: Color(0x143D7DF5),
offset: Offset(0, 7.5),
blurRadius: 5.6,
),
],
),
onTap: _handleSave,
),
],
);
}
Widget _buildButton({
required String text,
required double width,
required Color textColor,
required Decoration decoration,
required VoidCallback onTap,
}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: width,
height: 49.h,
alignment: Alignment.center,
decoration: decoration,
child: Text(
text,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w500,
color: textColor,
),
),
),
);
}
Widget _defaultAvatar() {
return CachedNetworkImage(
imageUrl: ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png'),
fit: BoxFit.cover,
);
}
// ===================== 截图 / 分享 / 保存 =====================
Future<dynamic> _capturePoster() async {
final boundary = _boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return null;
final image = await boundary.toImage(pixelRatio: 3);
final byteData =
await image.toByteData(format: ui.ImageByteFormat.png);
return byteData?.buffer.asUint8List();
}
Future<void> _handleShare() async {
if (_busy) return;
setState(() => _busy = true);
try {
final bytes = await _capturePoster();
if (bytes == null) {
_toast('生成海报失败');
return;
}
final result = await ShareService.instance.shareImageBytes(
bytes: bytes,
fileName: 'conquer_poster.png',
);
if (result.status.name == 'unavailable') {
_toast('当前版本暂不支持分享');
}
} catch (e) {
_toast('生成海报失败');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _handleSave() async {
if (_busy) return;
setState(() => _busy = true);
try {
final bytes = await _capturePoster();
if (bytes == null) {
_toast('生成海报失败');
return;
}
final ok = await ShareService.saveToGalleryFromBytes(
bytes: bytes,
fileName:
'conquer_poster_${DateTime.now().millisecondsSinceEpoch}.png',
);
if (ok) {
// 先弹 toast(挂在根 Overlay),再关闭海报弹框
_toast('保存成功');
widget.onClose();
} else {
_toast('保存失败');
}
} catch (e) {
_toast('保存失败');
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _toast(String text) {
if (!mounted) return;
CenterToast.show(context, text);
}
}
@@ -0,0 +1,168 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/auth/api.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart';
/// ============================================================
/// 【GuideModal】攻克报告后功能引导遮罩
/// 1:1 对标 Vue components/GuideModal/GuideModal.vue
///
/// 行为:点击任意位置 → ChangeShowGuide → 刷新用户信息 → 关闭
/// ============================================================
class GuideModal extends ConsumerStatefulWidget {
const GuideModal({
super.key,
required this.show,
required this.onClose,
});
final bool show;
final VoidCallback onClose;
@override
ConsumerState<GuideModal> createState() => _GuideModalState();
}
class _GuideModalState extends ConsumerState<GuideModal> {
bool _closing = false;
Future<void> _handleClose() async {
if (_closing) return;
_closing = true;
try {
await UserApi.changeShowGuide();
// 强制刷新全局用户信息(失败也不阻塞关闭,refresh 内部已兜底)
await ref.read(currentUserProvider.notifier).refresh();
} catch (_) {
// 对标 Vue:接口失败仅打印,不影响关闭
} finally {
widget.onClose();
}
}
@override
Widget build(BuildContext context) {
if (!widget.show) return const SizedBox.shrink();
final pad = isPadDevice(context);
final buttonWidth = pad ? 255.w : 165.w;
return Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleClose,
child: Stack(
children: [
const Positioned.fill(
child: ColoredBox(color: Color(0x8C000000)), // rgba(0,0,0,.55)
),
// 「点击任意位置退出」居中白字
Positioned.fill(
child: Center(
child: Text(
'点击任意位置退出',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w400,
height: 1.375,
),
),
),
),
// 引导图 bottom 292rpx
Positioned(
left: 0,
right: 0,
bottom: 146.h,
height: 139.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-06-09/guide_pg1781012082368.png',
),
fit: BoxFit.contain,
),
),
// 箭头 bottom 206rpx / left 180rpx
Positioned(
left: 90.w,
bottom: 103.h,
width: 12.w,
height: 50.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-06-09/guide_arrow1781012131243.png',
),
fit: BoxFit.contain,
),
),
// 假按钮组 bottom 80rpx
Positioned(
left: 0,
right: 0,
bottom: 40.h,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: buttonWidth,
height: 49.h,
margin: EdgeInsets.only(right: 17.w),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(50.r),
border: Border.all(
color: const Color(0xFF66A6FF),
width: 1.w,
),
),
child: Text(
'全部解析',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: const Color(0xFF66A6FF),
),
),
),
Container(
width: buttonWidth,
height: 49.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF3373FF), Color(0xFF3D7DF5)],
),
borderRadius: BorderRadius.circular(50.r),
boxShadow: const [
BoxShadow(
color: Color(0x4D3D7DF5),
offset: Offset(0, 7.5),
blurRadius: 15,
),
],
),
child: Text(
'错题解析',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,166 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【SemicircleProgress】半圆正确率进度环
/// 1:1 对标 Vue components/lProgressCircle/lProgressCircle.vue
/// (report.vue 用法:width 308rpx / height 168rpx / strokeWidth 14 /
/// bgColor #D8DFEB / progressColor #1578FF,上半圆从左到右)
///
/// 尺寸说明(Vue canvas 逻辑像素 → Flutter dp):
/// 308rpx = 154.w,168rpx = 84.h,描边 14px = 7.w,
/// paddingTop 默认 8px = 4.h;
/// radius = min(宽/2 - 描边, 高 - 描边 - paddingTop)
/// ============================================================
class SemicircleProgress extends StatelessWidget {
const SemicircleProgress({
super.key,
required this.percent,
this.width = 154,
this.height = 84,
this.strokeWidth = 10,
this.paddingTop = 4,
this.trackColor = const Color(0xFFD8DFEB),
this.progressColor = const Color(0xFF1578FF),
});
/// 0 ~ 100
final num percent;
final double width;
final double height;
final double strokeWidth;
final double paddingTop;
final Color trackColor;
final Color progressColor;
@override
Widget build(BuildContext context) {
final w = width.w;
final h = height.h;
final sw = strokeWidth.w;
return SizedBox(
width: w,
height: h,
child: CustomPaint(
size: Size(w, h),
painter: _SemicirclePainter(
percent: percent.toDouble().clamp(0, 100),
strokeWidth: sw,
paddingTop: paddingTop.h,
trackColor: trackColor,
progressColor: progressColor,
),
// 对标 Vue slot:progress-content top 43rpx,居中展示数值
child: Padding(
padding: EdgeInsets.only(top: 31.5.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 大数字 + %(font-50 粗体标题色 + font-24 副色 %,底 10rpx)
Padding(
padding: EdgeInsets.only(bottom: 5.h),
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: '${percent % 1 == 0 ? percent.toInt() : percent}',
style: TextStyle(
fontSize: 25.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
TextSpan(
text: '%',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF495A7A),
),
),
],
),
),
),
Text(
'本次正确率',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF687FAD),
),
),
],
),
),
),
);
}
}
class _SemicirclePainter extends CustomPainter {
const _SemicirclePainter({
required this.percent,
required this.strokeWidth,
required this.paddingTop,
required this.trackColor,
required this.progressColor,
});
final double percent;
final double strokeWidth;
final double paddingTop;
final Color trackColor;
final Color progressColor;
@override
void paint(Canvas canvas, Size size) {
final halfStroke = strokeWidth / 2;
final maxRadiusByWidth = size.width / 2 - strokeWidth;
final maxRadiusByHeight = size.height - strokeWidth - paddingTop;
final radius = math.max(0.0, math.min(maxRadiusByWidth, maxRadiusByHeight));
final centerX = size.width / 2;
final centerY = size.height - halfStroke;
final trackPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = trackColor;
// 背景半圆环:π(左)→ 0(右)
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
math.pi,
math.pi,
false,
trackPaint,
);
// 进度弧
if (percent > 0) {
final progressPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = progressColor;
final sweep = math.pi * (percent / 100);
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
math.pi,
sweep,
false,
progressPaint,
);
}
}
@override
bool shouldRepaint(covariant _SemicirclePainter oldDelegate) {
return oldDelegate.percent != percent ||
oldDelegate.strokeWidth != strokeWidth ||
oldDelegate.trackColor != trackColor ||
oldDelegate.progressColor != progressColor;
}
}
@@ -0,0 +1,747 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/api.dart';
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart';
/// ============================================================
/// 【TransitionPage】课前/课中/课后 过渡页面
/// 对标 Vue pages/conquerPackage/transitionPage/transitionPage.vue
///
/// 一、三种状态:
/// status = 1 → 课前讲义(带滑动蒙层引导)
/// status = 2 → 课中视频
/// status = 3 → 课后练习(含提示卡片 + 攻克标准)
///
/// 二、路由参数(通过 extra 传入):
/// - courseId 课程 id
/// - packId 攻克包 id
/// - recordId 学习记录 id
/// - status 状态:1课前 / 2课中 / 3课后
///
/// 三、尺寸换算(本工程统一):
/// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r
///
/// 四、跳转:
/// status 1/2 → 跳 beforeDuringClass 页
/// status 3 → 跳 classExercises 页
/// ============================================================
/// 过渡页状态
enum TransitionStatus {
beforeClass(1), // 课前讲义
duringClass(2), // 课中视频
afterClass(3); // 课后练习
final int value;
const TransitionStatus(this.value);
factory TransitionStatus.fromInt(dynamic v) {
final n = int.tryParse(v?.toString() ?? '') ?? 1;
return TransitionStatus.values.firstWhere(
(e) => e.value == n,
orElse: () => TransitionStatus.beforeClass,
);
}
}
class TransitionPage extends StatefulWidget {
const TransitionPage({super.key, this.extra});
final Map<String, dynamic>? extra;
@override
State<TransitionPage> createState() => _TransitionPageState();
}
class _TransitionPageState extends State<TransitionPage> {
// ========== 颜色常量(对标 Vue common.scss / pages.json) ==========
// pages.json globalStyle.backgroundColor
static const Color _bgColor = Color(0xFFF8F8F8);
// color-Subtitle(主标题 / 提示文案均用此色)
static const Color _textColor = Color(0xFF495A7A);
// color-default $default-color
static const Color _primary = Color(0xFF3D7DF5);
// $default-color-strong(渐变起点)
static const Color _primaryStrong = Color(0xFF3373FF);
static const Color _labelBg = Color(0xFFE3ECFF);
static const Color _cardBg = Color(0xFFF2F7FF);
// ========== 路由参数 ==========
late String _courseId;
late String _packId;
late String _recordId;
late TransitionStatus _status;
// ========== 数据 ==========
OvercomeRecordInfo _overcomeInfo = const OvercomeRecordInfo();
bool _loading = true;
bool _showMask = true; // 课前蒙层是否显示
@override
void initState() {
super.initState();
_parseExtra();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadData();
});
}
// ===================== 初始化 =====================
void _parseExtra() {
final extra = widget.extra ?? const <String, dynamic>{};
_courseId = (extra['courseId']?.toString()) ?? '';
_packId = (extra['packId']?.toString()) ?? '';
_recordId = (extra['recordId']?.toString()) ?? '';
_status = TransitionStatus.fromInt(extra['status']);
}
Future<void> _loadData() async {
setState(() => _loading = true);
try {
if (_recordId.isEmpty) {
throw Exception('recordId 无效');
}
final info = await BeforeDuringClassApi.instance.getRecord(_recordId);
if (!mounted) return;
setState(() => _overcomeInfo = info);
} catch (e) {
debugPrint('[TransitionPage] 加载失败: $e');
// 失败时回退到攻克列表页
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('获取攻克信息失败:${e.toString()}'),
behavior: SnackBarBehavior.floating,
),
);
context.go('/conquerPackage/captureList', extra: _courseId);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
// ===================== 点击开始学习 =====================
void _startLearning() {
if (_status == TransitionStatus.beforeClass ||
_status == TransitionStatus.duringClass) {
context.push(
'/conquerPackage/beforeDuringClass',
extra: {
'status': '${_status.value}',
'courseId': _courseId,
'packId': _packId,
'recordId': _recordId,
},
);
} else if (_status == TransitionStatus.afterClass) {
if (_overcomeInfo.hasQuestion) {
// 对标 Vue:课后练习跳 classExercises(仅 courseId/packId/recordId)
context.push(
'/conquerPackage/classExercises',
extra: {
'courseId': _courseId,
'packId': _packId,
'recordId': _recordId,
},
);
}
}
}
// ===================== Build =====================
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _bgColor,
body: Stack(
children: [
SafeArea(
child: Column(
children: [
_buildNavBar(),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _buildBody(),
),
],
),
),
// 课前/课中:开始学习按钮 fixed 底部 92rpx
if (!_loading &&
(_status == TransitionStatus.beforeClass ||
_status == TransitionStatus.duringClass))
Positioned(
left: 0,
right: 0,
bottom: 0,
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(14.w, 0, 14.w, 46.h),
child: SizedBox(
height: 46.h,
child: _buildPrimaryButton('开始学习', _startLearning),
),
),
),
),
// 课前引导蒙层
if (_status == TransitionStatus.beforeClass && _showMask)
_SlideMask(
visible: _showMask,
onClose: () => setState(() => _showMask = false),
),
],
),
);
}
// ===================== NavBar(对标 NavBar.vue:透明底 + return.png) =====================
Widget _buildNavBar() {
return SizedBox(
height: 44.h,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// side padding-left 24rpx + back padding-left 4rpx = 28rpx
SizedBox(width: 14.w),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).maybePop(),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10.h),
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/return.png',
),
width: 24.w,
height: 24.h,
placeholder: (_, __) => Icon(
Icons.arrow_back_ios,
size: 18.w,
color: const Color(0xCC000000),
),
errorWidget: (_, __, ___) => Icon(
Icons.arrow_back_ios,
size: 18.w,
color: const Color(0xCC000000),
),
),
),
),
],
),
);
}
// ===================== Body =====================
Widget _buildBody() {
switch (_status) {
case TransitionStatus.beforeClass:
return _buildBeforeClass();
case TransitionStatus.duringClass:
return _buildDuringClass();
case TransitionStatus.afterClass:
return _buildAfterClass();
}
}
// ---------- 课前讲义 ----------
Widget _buildBeforeClass() {
return SingleChildScrollView(
padding: EdgeInsets.only(bottom: 120.h),
child: Column(
children: [
// 插图:340x298rpx,margin 117rpx 顶 / 100rpx 底
SizedBox(height: 58.5.h),
_buildBacImage(
'/StaticResource/2026-04-01/before-class-bac.png',
width: 170.w,
height: 149.h,
fallback: Icons.menu_book_outlined,
),
SizedBox(height: 50.h),
// 标签
_buildLabel('课前讲义'),
// 标题
_buildTitle(subtitle: '知识点讲义学习'),
],
),
);
}
// ---------- 课中视频 ----------
Widget _buildDuringClass() {
return SingleChildScrollView(
padding: EdgeInsets.only(bottom: 120.h),
child: Column(
children: [
// 插图:372x240rpx,margin 160rpx 顶 / 85rpx 底
SizedBox(height: 80.h),
_buildBacImage(
'/StaticResource/2026-04-01/during-class-bac.png',
width: 186.w,
height: 120.h,
fallback: Icons.play_circle_outline,
),
SizedBox(height: 42.5.h),
_buildLabel('课中视频'),
_buildTitle(subtitle: '知识点视频学习'),
],
),
);
}
// ---------- 课后练习 ----------
Widget _buildAfterClass() {
final isPad = isPadDevice(context);
return SingleChildScrollView(
child: Column(
children: [
// 插图:340x340rpx(Pad 400x400rpx),margin 110rpx 顶
SizedBox(height: 55.h),
_buildBacImage(
isPad
? '/StaticResource/2026-06-02/after-class-bac-Pad1780380978172.png'
: '/StaticResource/2026-04-01/after-class-bac.png',
width: isPad ? 200.w : 170.w,
height: isPad ? 200.h : 170.h,
fallback: Icons.assignment_outlined,
),
// 底 margin:手机 50rpx / Pad 116rpx
SizedBox(height: isPad ? 58.h : 25.h),
_buildLabel('课后练习'),
if (_overcomeInfo.name.isNotEmpty)
_buildTitle(
subtitle: '知识点题目攻克',
padding: EdgeInsets.symmetric(horizontal: 5.w),
),
// 提示卡片
_buildAfterTipsCard(isPad),
SizedBox(height: 14.h),
],
),
);
}
// ===================== 公共组件 =====================
/// 顶部插图(对标各 .xxx-bac 样式,margin auto 居中)
Widget _buildBacImage(
String url, {
required double width,
required double height,
required IconData fallback,
}) {
return Center(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(url),
width: width,
height: height,
fit: BoxFit.contain,
placeholder: (_, __) => SizedBox.shrink(),
errorWidget: (_, __, ___) => SizedBox(
width: width,
height: height,
child: Icon(fallback, size: 80, color: _primary),
),
),
);
}
/// .label:120x40rpx,#E3ECFF 底,8rpx 圆角,24rpx 主色字
Widget _buildLabel(String text) {
return Container(
width: 60.w,
height: 20.h,
alignment: Alignment.center,
margin: EdgeInsets.only(bottom: 12.h),
decoration: BoxDecoration(
color: _labelBg,
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
text,
style: TextStyle(
fontSize: 12.sp,
color: _primary,
),
),
);
}
/// .color-Subtitle.font-36.font-bold600.tc:《name》+ 副标题两行
Widget _buildTitle({
required String subtitle,
EdgeInsetsGeometry? padding,
}) {
final name = _overcomeInfo.name;
final content = name.isNotEmpty ? '《$name》\n$subtitle' : subtitle;
return Padding(
padding: padding ?? EdgeInsets.zero,
child: Text(
content,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: _textColor,
),
),
);
}
/// .default-button1:
/// 高 92rpx、横向渐变 #3373FF→#3D7DF5、100rpx 全圆角、
/// 阴影 0 15rpx 30rpx rgba(61,125,245,.3)、36rpx 白字
Widget _buildPrimaryButton(String text, VoidCallback? onPressed) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: Container(
height: 46.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [_primaryStrong, _primary],
),
borderRadius: BorderRadius.circular(50.r),
boxShadow: [
BoxShadow(
color: const Color(0x4D3D7DF5),
offset: Offset(0, 7.5.h),
blurRadius: 15.h,
),
],
),
child: Text(
text,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
);
}
// ===================== 课后提示卡片(.after-tips) =====================
Widget _buildAfterTipsCard(bool isPad) {
return Align(
alignment: Alignment.topCenter,
child: Container(
// 手机:margin 56rpx 28rpx 0;Pad:margin 260rpx auto 0,宽 1172rpx
margin: isPad
? EdgeInsets.only(top: 130.h)
: EdgeInsets.only(left: 14.w, right: 14.w, top: 28.h),
width: isPad ? 586.w : double.infinity,
decoration: BoxDecoration(
color: _cardBg,
borderRadius: BorderRadius.circular(12.r),
image: DecorationImage(
image: NetworkImage(
ImgUrl.build(
isPad
? '/StaticResource/2026-06-02/after-class-tips-Pad1780381600915.png'
: '/StaticResource/2026-04-01/after-class-tips.png',
),
),
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
// 内边距:手机 258rpx 28rpx 28rpx / Pad 358rpx 28rpx 28rpx
Padding(
padding: EdgeInsets.fromLTRB(
14.w,
isPad ? 179.h : 129.h,
14.w,
14.h,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTipItem('包含单选、多选、判断、填空全题型'),
_buildTipItem('每题展示深度解析与解题逻辑'),
// 对标 Vue v-if="overcomeInfo.hasQuestion"
if (_overcomeInfo.hasQuestion)
Padding(
padding: EdgeInsets.only(top: 15.h),
child: SizedBox(
width: double.infinity,
height: 46.h,
child: _buildPrimaryButton('正式进入攻克', _startLearning),
),
),
],
),
),
// 攻克标准百分比(.after-tips-num 绝对定位)
Positioned(
top: isPad ? 95.h : 65.h,
right: isPad ? 200.w : 87.5.w,
child: Text(
'${_overcomeInfo.overcomeStandard}%',
style: TextStyle(
fontSize: isPad ? 19.sp : 16.sp,
fontWeight: FontWeight.w600,
color: _primary,
),
),
),
],
),
),
);
}
/// .after-tips-li:28rpx 副色文字,左 padding 48rpx,底 padding 8rpx;
/// ::before 圆点 8rpx #3D7DF5,left 14rpx 垂直居中
Widget _buildTipItem(String text) {
return Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: EdgeInsets.only(left: 24.w, bottom: 4.h),
child: Text(
text,
style: TextStyle(
fontSize: 14.sp,
color: _textColor,
),
),
),
Positioned(
left: 7.w,
top: 0,
bottom: 0,
child: Center(
child: Container(
width: 4.w,
height: 4.w,
decoration: const BoxDecoration(
color: _primary,
shape: BoxShape.circle,
),
),
),
),
],
);
}
}
/// ============================================================
/// 【_SlideMask】滑动蒙层引导组件
/// 对标 Vue pages/conquerPackage/transitionPage/slide-mask.vue
///
/// 功能:
/// - 半透明黑色蒙层背景(rgba(0,0,0,.55))
/// - 显示引导图
/// - 向左滑动超过阈值 → 整个蒙层 0.2s 滑出屏幕并触发 onClose
/// - 滑动不足阈值 → 0.15s 回弹
/// ============================================================
class _SlideMask extends StatefulWidget {
const _SlideMask({
required this.visible,
required this.onClose,
this.slideThreshold = 80.0,
});
final bool visible;
final VoidCallback onClose;
final double slideThreshold;
@override
State<_SlideMask> createState() => _SlideMaskState();
}
class _SlideMaskState extends State<_SlideMask>
with SingleTickerProviderStateMixin {
double _offsetX = 0;
double _startX = 0;
bool _isSliding = false;
bool _isAnimatingOut = false;
late final AnimationController _controller;
Animation<double> _anim = const AlwaysStoppedAnimation<double>(0);
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant _SlideMask oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible && !oldWidget.visible) {
_resetState();
}
}
void _resetState() {
_controller.stop();
_controller.reset();
_anim = const AlwaysStoppedAnimation<double>(0);
_offsetX = 0;
_isSliding = false;
_isAnimatingOut = false;
}
/// 偏移量动画(跟手结束后:回弹 / 滑出)
void _animateTo(
double target, {
required int milliseconds,
required Curve curve,
VoidCallback? onCompleted,
}) {
_controller.duration = Duration(milliseconds: milliseconds);
final animation = Tween<double>(begin: _offsetX, end: target).animate(
CurvedAnimation(parent: _controller, curve: curve),
);
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) onCompleted?.call();
});
setState(() => _anim = animation);
_controller.forward(from: 0);
}
void _handleTouchStart(DragStartDetails details) {
if (!widget.visible || _isAnimatingOut) return;
_controller.stop();
_startX = details.globalPosition.dx;
_isSliding = true;
// 对标 Vue:touchstart 时偏移量归零
setState(() => _offsetX = 0);
}
void _handleTouchUpdate(DragUpdateDetails details) {
if (!widget.visible || !_isSliding || _isAnimatingOut) return;
final moveX = details.globalPosition.dx;
final diffX = _startX - moveX;
// 仅处理向左滑动
if (diffX > 0) {
setState(() => _offsetX = -diffX);
}
}
void _handleTouchEnd(DragEndDetails details) {
if (!widget.visible || !_isSliding) return;
_isSliding = false;
if (_offsetX.abs() >= widget.slideThreshold) {
// 超过阈值 → 0.2s 滑出动画
_startSlideOut();
} else {
// 不足阈值 → 0.15s ease-out 回弹
_animateTo(
0,
milliseconds: 150,
curve: Curves.easeOut,
onCompleted: () {
if (mounted) setState(() => _offsetX = 0);
},
);
}
}
void _startSlideOut() {
_isAnimatingOut = true;
final screenWidth = MediaQuery.of(context).size.width;
_animateTo(
-screenWidth,
milliseconds: 200,
curve: Curves.easeInOut,
onCompleted: () {
if (mounted) widget.onClose();
},
);
}
@override
Widget build(BuildContext context) {
if (!widget.visible) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _anim,
builder: (context, child) {
final dx = _isSliding ? _offsetX : _anim.value;
return Positioned.fill(
child: GestureDetector(
onHorizontalDragStart: _handleTouchStart,
onHorizontalDragUpdate: _handleTouchUpdate,
onHorizontalDragEnd: _handleTouchEnd,
behavior: HitTestBehavior.opaque,
child: Transform.translate(
offset: Offset(dx, 0),
child: Container(
color: const Color(0x8C000000), // 55% 透明黑
child: Stack(
children: [
// 引导图:489x245rpx,bottom 374rpx,水平居中
Positioned(
left: 0,
right: 0,
bottom: 187.h,
child: Center(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/guidance.png',
),
width: 244.5.w,
height: 122.5.h,
fit: BoxFit.contain,
placeholder: (_, __) => SizedBox.shrink(),
errorWidget: (_, __, ___) => SizedBox(
width: 244.5.w,
height: 122.5.h,
child: const Icon(
Icons.swipe_left,
color: Colors.white,
size: 60,
),
),
),
),
),
],
),
),
),
),
);
},
);
}
}
+1 -1
View File
@@ -240,7 +240,7 @@ class TaskPage extends StatelessWidget {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.push('/conquerPackage/captureList',
extra: '841985856751366');
extra: '841985856751368');
}
});
},
+40
View File
@@ -675,6 +675,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -1143,6 +1151,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -1748,6 +1764,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
vector_math:
dependency: transitive
description:
+2
View File
@@ -119,6 +119,7 @@ dependencies:
# 替换(customWidgetBuilder),video 留扩展点,图片/表格原生支持。
# 版本:0.17.x 要求 Flutter 3.32+ / just_audio ^0.10.4(本项目满足)
flutter_widget_from_html_core: ^0.17.2
flutter_svg: ^2.0.17
# 图片预览:社区成熟方案,支持双指缩放/拖拽/多图左右切换
# 单图用 PhotoView,多图用 PhotoViewGallery;
@@ -240,6 +241,7 @@ flutter:
# 所以 img/tabbar 下的 PNG 必须单独列一行。
assets:
- assets/
- assets/mock/
- assets/img/tabbar/
- assets/img/tabbar/default/
- assets/audio/
+42
View File
@@ -0,0 +1,42 @@
import 'dart:convert';
import 'dart:io';
import 'package:new_learn/core/utils/question/exam_answer_card.dart';
import 'package:new_learn/core/utils/question/question_parser.dart';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
void main() {
final raw = File(
'assets/mock/class_exercises_edit.json',
).readAsStringSync();
final list = jsonDecode(raw) as List;
for (final subjectId in [0, 2, 3]) {
print('########## subjectId=$subjectId ##########');
for (final idx in [11, 12]) {
final item = Map<String, dynamic>.from(list[idx] as Map);
final parsed = parseQuestionData(item, mergeOriginalChilds: true);
var sorted = appendQuestionSort([parsed]);
sorted = appendExamQuestionChildOptionStatus(sorted, subjectId);
final q = QuestionModel(sorted.first);
print(
'Q$idx ${q.questionTypes}: hasOptionChildrenQuestion='
'${q.hasOptionChildrenQuestion} subs=${q.subQuestions.length} '
'childs=${q.childs.length}',
);
for (var i = 0; i < q.subQuestions.length; i++) {
final s = q.subQuestions[i];
final cleaned = cleanRichText(stripOptionTable(s.stem));
print(
' sub$i options=${s.options.length} '
'cleanedStemLen=${cleaned.length} '
'cleanedStem=${cleaned.length > 120 ? cleaned.substring(0, 120) : cleaned}',
);
if (i == 1) break;
}
if (q.subQuestions.length > 2) print(' ...');
}
}
}