Compare commits
23
Commits
9017ec669f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63950afe8 | ||
|
|
5f53bd8f94 | ||
|
|
847986f861 | ||
|
|
64060831c8 | ||
|
|
27eba92315 | ||
|
|
fe86b6d824 | ||
|
|
fe060ac974 | ||
|
|
c65df455f2 | ||
|
|
14b8e56bc3 | ||
|
|
96ab0221a5 | ||
|
|
0fefc786bb | ||
|
|
e0b95a8b43 | ||
|
|
5e4cfe9ac6 | ||
|
|
ee14373c6d | ||
|
|
0ce3c0b3ef | ||
|
|
de3457c86e | ||
|
|
7acf745295 | ||
|
|
592e576319 | ||
|
|
c0c673e2d2 | ||
|
|
955d6455d8 | ||
|
|
f718749ad7 | ||
|
|
7f5d160212 | ||
|
|
44fb5f86e2 |
@@ -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
@@ -23,3 +23,33 @@ plugins {
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
// ============================================================
|
||||
// 【Gradle 兼容补丁】flutter_inappwebview_android 1.1.3 的 AGP 9 兼容问题
|
||||
//
|
||||
// 问题:AGP 9 不支持 proguard-android.txt,必须用 proguard-android-optimize.txt。
|
||||
// 插件 build.gradle 写死了 proguard-android.txt → Gradle 解析时就报错
|
||||
// (debug 构建也炸,因为 AGP 9 在解析阶段就校验了这个方法调用)。
|
||||
//
|
||||
// 修复:在 Gradle 解析插件 build.gradle 之前(beforeProject 钩子),
|
||||
// 把插件文件里的字符串替换掉。这个钩子比 Gradle 自带机制还早触发,
|
||||
// 所以能赶在 Gradle 报错前修好。
|
||||
//
|
||||
// 优势:
|
||||
// - Gradle 原生,不依赖 Python/PowerShell
|
||||
// - 跨平台(Windows/Mac/Linux,Gradle 必备)
|
||||
// - 所有同事拉代码后自动生效
|
||||
// - Gradle daemon 每次构建都会重新解析 build.gradle,不会有缓存问题
|
||||
// ============================================================
|
||||
gradle.beforeProject { Project project ->
|
||||
if (project.name == 'flutter_inappwebview_android') {
|
||||
def gradleFile = project.file('build.gradle')
|
||||
def text = gradleFile.text
|
||||
if (text.contains("proguard-android.txt")) {
|
||||
gradleFile.text = text.replace(
|
||||
"proguard-android.txt", "proguard-android-optimize.txt"
|
||||
)
|
||||
println "✅ Patched flutter_inappwebview_android/build.gradle (AGP 9 compat)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 层完成;
|
||||
@@ -89,6 +88,83 @@ class UserApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// 注销账号 → POST /api/v1/User/CloseAccount
|
||||
///
|
||||
/// 对齐 Vue api/login: `closeAccount = () => request.post('/api/v1/User/CloseAccount')`
|
||||
/// 调用方负责弹确认框 + 注销后清理本地状态。
|
||||
static Future<void> closeAccount() async {
|
||||
await DioClient.instance.post('/api/v1/User/CloseAccount');
|
||||
}
|
||||
|
||||
// ==================== 注册 ====================
|
||||
|
||||
/// 注册 → POST /api/v1/User/Register
|
||||
///
|
||||
/// 对齐 Vue api/login `register`: { account, password },密码同样 MD5 加密
|
||||
/// Vue RegisterResponse = { code, message }(业务响应壳),Flutter 端 DioClient
|
||||
/// 已自动处理成功/失败分支,成功不关心 body,失败直接抛 ApiException。
|
||||
static Future<void> register({
|
||||
required String account,
|
||||
required String password,
|
||||
}) async {
|
||||
await DioClient.instance.post(
|
||||
'/api/v1/User/Register',
|
||||
data: {'account': account.trim(), 'password': _md5(password.trim())},
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 审核模式 ====================
|
||||
|
||||
/// 获取审核模式(决定是否展示"去注册"入口)→ GET /api/v1/Public/GetIsAuditMode
|
||||
///
|
||||
/// 对齐 Vue api/login: `getAuditMode()`,返回值本身就是 boolean
|
||||
/// (request 拦截器已解包),接口不可用时安全回退 false(不展示注册入口)。
|
||||
static Future<bool> getAuditMode() async {
|
||||
try {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Public/GetIsAuditMode',
|
||||
);
|
||||
if (data is bool) return data;
|
||||
if (data is Map) {
|
||||
// 兼容 { isAuditMode: true } 包装
|
||||
final v = data['isAuditMode'];
|
||||
if (v is bool) return v;
|
||||
}
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 报告状态更新 ====================
|
||||
|
||||
/// 更新入门测试报告显示状态(看完后置回 false)
|
||||
/// → POST /api/v1/User/ChangeShowReport
|
||||
///
|
||||
/// 对齐 Vue api/user/index.ts 的 UpdateReportStatus(),
|
||||
/// 在 reportComputation finishedPopup 点"立即开启学习蝶变"后调用。
|
||||
static Future<void> changeShowReport() async {
|
||||
await DioClient.instance.post('/api/v1/User/ChangeShowReport');
|
||||
}
|
||||
|
||||
/// 更新出门测试报告显示状态(看完后置回 false)
|
||||
/// → POST /api/v1/User/ChangeShowAfterReport
|
||||
///
|
||||
/// 对齐 H5 壳子 api/mock/index.ts 的 updateAfterReportStatus(),
|
||||
/// 在出门测 H5 壳子 navigateReport() 里由 Flutter JS Bridge 拦截后调用。
|
||||
static Future<void> changeShowAfterReport() async {
|
||||
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();
|
||||
|
||||
@@ -55,6 +55,7 @@ class UserInfo {
|
||||
this.avatar = '',
|
||||
this.phone = '',
|
||||
this.isShowReport = false,
|
||||
this.isShowAfterReport = false,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
@@ -64,6 +65,9 @@ class UserInfo {
|
||||
final String phone;
|
||||
final bool isShowReport;
|
||||
|
||||
/// 登录响应里的出门测试报告标记(对齐 Vue api/login/type.ts 的 UserInfo)
|
||||
final bool isShowAfterReport;
|
||||
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
// userId 可能是字符串(后端统一风格),不能直接 as num
|
||||
@@ -73,6 +77,7 @@ class UserInfo {
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
isShowReport: json['isShowReport'] as bool? ?? false,
|
||||
isShowAfterReport: json['isShowAfterReport'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【课前课中模块接口】讲义列表 / 学习时长更新 / 提交报告
|
||||
///
|
||||
/// 对标 Vue 端 api/task/index.ts 中的相关接口:
|
||||
///
|
||||
/// getListByRecordHandout
|
||||
/// —— 根据学习记录获取讲义列表
|
||||
/// GET /api/v1/Handout/GetListByRecord/{recordId}
|
||||
///
|
||||
/// startOvercome
|
||||
/// —— 开始/继续攻克学习包(返回 recordId 等)
|
||||
/// POST /api/v1/StudyPack/StartOvercome/{packId}
|
||||
///
|
||||
/// renewHandoutTime
|
||||
/// —— 更新讲义学习时长
|
||||
/// POST /api/v1/Handout/RenewTime
|
||||
///
|
||||
/// getSubmitReport
|
||||
/// —— 获取攻克报告(view 模式数据源)
|
||||
/// GET /api/v1/StudyPack/GetReport/{recordId}
|
||||
///
|
||||
/// getRecord
|
||||
/// —— 获取攻克记录信息(过渡页使用)
|
||||
/// GET /api/v1/StudyPack/GetRecord/{recordId}
|
||||
///
|
||||
/// 注意:
|
||||
/// - 页面只调这里,不直接碰 Dio;
|
||||
/// - 异常不在 catch,交页面统一处理;
|
||||
/// - view 模式下接口返回的 handouts 列表格式一致,统一走 HandoutListResponse。
|
||||
/// ============================================================
|
||||
class BeforeDuringClassApi {
|
||||
BeforeDuringClassApi._();
|
||||
|
||||
static final BeforeDuringClassApi instance = BeforeDuringClassApi._();
|
||||
|
||||
/// 根据学习记录获取讲义列表 → GET /api/v1/Handout/GetListByRecord/{recordId}
|
||||
Future<HandoutListResponse> getListByRecordHandout(String recordId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Handout/GetListByRecord/$recordId',
|
||||
);
|
||||
return HandoutListResponse.fromJson(data);
|
||||
}
|
||||
|
||||
/// 更新讲义学习时长 → POST /api/v1/Handout/RenewTime
|
||||
///
|
||||
/// [id] 讲义 id
|
||||
/// [courseId] 课程 id
|
||||
/// [startTime] 开始时间 ISO 字符串
|
||||
/// [endTime] 结束时间 ISO 字符串
|
||||
Future<void> renewHandoutTime({
|
||||
required String id,
|
||||
required String courseId,
|
||||
required String startTime,
|
||||
required String endTime,
|
||||
}) async {
|
||||
await DioClient.instance.post(
|
||||
'/api/v1/Handout/RenewTime',
|
||||
data: {
|
||||
'id': id,
|
||||
'courseId': courseId,
|
||||
'startTime': startTime,
|
||||
'endTime': endTime,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取攻克报告(view 模式) → GET /api/v1/StudyPack/GetReport/{recordId}
|
||||
///
|
||||
/// 返回结构中 handouts 字段对应讲义列表。
|
||||
Future<HandoutListResponse> getSubmitReport(String recordId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/StudyPack/GetReport/$recordId',
|
||||
);
|
||||
// 报告里 handouts 可能在 data.handouts 下
|
||||
if (data is Map<String, dynamic>) {
|
||||
final payload = data['data'] as Map<String, dynamic>? ?? data;
|
||||
return HandoutListResponse.fromJson(payload['handouts']);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/// ============================================================
|
||||
/// 【课前课中模块模型】讲义列表 / 材料 / 笔记
|
||||
///
|
||||
/// 对标 Vue 端 api/task/type.ts 中的 Handout 相关类型。
|
||||
/// 序列化:手动 fromJson,兼容 {code, message, data: {...}} 响应壳。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||
|
||||
/// 讲义材料类型
|
||||
enum HandoutMaterialType {
|
||||
video(1), // 视频
|
||||
audio(2), // 音频
|
||||
text(3), // 文本
|
||||
unknown(0);
|
||||
|
||||
final int value;
|
||||
const HandoutMaterialType(this.value);
|
||||
|
||||
factory HandoutMaterialType.fromInt(int? v) =>
|
||||
HandoutMaterialType.values.firstWhere(
|
||||
(e) => e.value == v,
|
||||
orElse: () => HandoutMaterialType.unknown,
|
||||
);
|
||||
}
|
||||
|
||||
/// 讲义材料项(视频/音频/文本)
|
||||
class HandoutMaterial {
|
||||
const HandoutMaterial({
|
||||
this.id = '',
|
||||
this.pageIndex = 0,
|
||||
this.materialType = HandoutMaterialType.unknown,
|
||||
this.materialContent = '',
|
||||
});
|
||||
|
||||
final String id; // 材料 id
|
||||
final int pageIndex; // 页码
|
||||
final HandoutMaterialType materialType; // 材料类型
|
||||
final String materialContent; // 内容(URL 或 HTML 文本)
|
||||
|
||||
factory HandoutMaterial.fromJson(Map<String, dynamic> json) {
|
||||
return HandoutMaterial(
|
||||
id: json['id']?.toString() ?? '',
|
||||
pageIndex: _parseInt(json['pageIndex']),
|
||||
materialType:
|
||||
HandoutMaterialType.fromInt(_parseInt(json['materialType'])),
|
||||
materialContent: json['materialContent'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'HandoutMaterial{id: $id, pageIndex: $pageIndex, '
|
||||
'type: ${materialType.name}, content: '
|
||||
'${materialContent.length > 50 ? '${materialContent.substring(0, 50)}...' : materialContent}}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 单页讲义
|
||||
class HandoutItem {
|
||||
const HandoutItem({
|
||||
this.id = '',
|
||||
this.handoutPageId = '',
|
||||
this.name = '',
|
||||
this.title = '',
|
||||
this.content = '',
|
||||
this.planStudyTimeSecond = 0,
|
||||
this.planStudyRemainTimeSecond = 0,
|
||||
this.studyTimeSecond = 0,
|
||||
this.isFinishPlanTime = false,
|
||||
this.isUploadNotes = false,
|
||||
this.materials = const [],
|
||||
this.notes = const [],
|
||||
});
|
||||
|
||||
final String id; // 讲义 id(用于 RenewTime / NoteAdd)
|
||||
final String handoutPageId; // 讲义页 id
|
||||
final String name; // 名称(页面顶部标题)
|
||||
final String title; // 标题(NavBar 显示)
|
||||
final String content; // 正文(富文本 HTML)
|
||||
final int planStudyTimeSecond; // 建议学习时长(秒)
|
||||
final int planStudyRemainTimeSecond; // 剩余学习时长(秒)
|
||||
final int studyTimeSecond; // 已学习时长(秒)
|
||||
final bool isFinishPlanTime; // 是否已完成建议时长
|
||||
final bool isUploadNotes; // 是否必须上传笔记
|
||||
final List<HandoutMaterial> materials; // 材料列表
|
||||
final List<NoteImage> notes; // 笔记图片列表
|
||||
|
||||
/// 深拷贝(用于修改某字段后触发 setState)
|
||||
HandoutItem copyWith({
|
||||
String? id,
|
||||
String? handoutPageId,
|
||||
String? name,
|
||||
String? title,
|
||||
String? content,
|
||||
int? planStudyTimeSecond,
|
||||
int? planStudyRemainTimeSecond,
|
||||
int? studyTimeSecond,
|
||||
bool? isFinishPlanTime,
|
||||
bool? isUploadNotes,
|
||||
List<HandoutMaterial>? materials,
|
||||
List<NoteImage>? notes,
|
||||
}) {
|
||||
return HandoutItem(
|
||||
id: id ?? this.id,
|
||||
handoutPageId: handoutPageId ?? this.handoutPageId,
|
||||
name: name ?? this.name,
|
||||
title: title ?? this.title,
|
||||
content: content ?? this.content,
|
||||
planStudyTimeSecond: planStudyTimeSecond ?? this.planStudyTimeSecond,
|
||||
planStudyRemainTimeSecond:
|
||||
planStudyRemainTimeSecond ?? this.planStudyRemainTimeSecond,
|
||||
studyTimeSecond: studyTimeSecond ?? this.studyTimeSecond,
|
||||
isFinishPlanTime: isFinishPlanTime ?? this.isFinishPlanTime,
|
||||
isUploadNotes: isUploadNotes ?? this.isUploadNotes,
|
||||
materials: materials ?? this.materials,
|
||||
notes: notes ?? this.notes,
|
||||
);
|
||||
}
|
||||
|
||||
factory HandoutItem.fromJson(Map<String, dynamic> json) {
|
||||
final materials = (json['materials'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(HandoutMaterial.fromJson)
|
||||
.toList();
|
||||
final notes = (json['notes'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(NoteImage.fromJson)
|
||||
.toList();
|
||||
return HandoutItem(
|
||||
id: (json['id']?.toString()) ?? '',
|
||||
handoutPageId: (json['handoutPageId']?.toString()) ?? '',
|
||||
name: json['name'] as String? ?? json['title'] as String? ?? '',
|
||||
title: json['title'] as String? ?? json['name'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
planStudyTimeSecond: _parseInt(json['planStudyTimeSecond']),
|
||||
planStudyRemainTimeSecond: _parseInt(json['planStudyRemainTimeSecond']),
|
||||
studyTimeSecond: _parseInt(json['studyTimeSecond']),
|
||||
isFinishPlanTime:
|
||||
json['ISFinishPlanTime'] as bool? ?? json['isFinishPlanTime'] as bool? ?? false,
|
||||
isUploadNotes: json['isUploadNotes'] as bool? ?? false,
|
||||
materials: materials,
|
||||
notes: notes,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'HandoutItem{id: $id, name: $name, planTime: $planStudyTimeSecond, '
|
||||
'studyTime: $studyTimeSecond, isFinishPlanTime: $isFinishPlanTime, '
|
||||
'isUploadNotes: $isUploadNotes, materials: ${materials.length}, '
|
||||
'notes: ${notes.length}}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 攻克记录信息(过渡页使用,对标 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 []});
|
||||
|
||||
final List<HandoutItem> items;
|
||||
|
||||
/// 兼容响应壳 {code, message, data: [...]},也兼容直接返回数组
|
||||
factory HandoutListResponse.fromJson(dynamic data) {
|
||||
List<dynamic> list;
|
||||
if (data is List) {
|
||||
list = data;
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
final payload = data['data'] ?? data['items'] ?? data['handouts'];
|
||||
list = (payload is List) ? payload : const [];
|
||||
} else {
|
||||
list = const [];
|
||||
}
|
||||
return HandoutListResponse(
|
||||
items: list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(HandoutItem.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解析 int
|
||||
int _parseInt(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is int) return value;
|
||||
if (value is double) return value.toInt();
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【captureList 模块接口】攻克学习包 —— 对标 Vue3 端 api/task/index.ts
|
||||
///
|
||||
/// getCoursePack —— 获取课程下的攻克学习包列表
|
||||
/// GET /api/v1/StudyPack/GetCoursePacks/{courseId}
|
||||
///
|
||||
/// startOvercome —— 开始 / 继续攻克学习包
|
||||
/// POST /api/v1/StudyPack/StartOvercome/{packId}
|
||||
/// ============================================================
|
||||
class CaptureListApi {
|
||||
CaptureListApi._();
|
||||
|
||||
static final CaptureListApi instance = CaptureListApi._();
|
||||
|
||||
/// 获取课程下的攻克学习包列表
|
||||
Future<CoursePackResponse> getCoursePack(String courseId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/StudyPack/GetCoursePacks/$courseId',
|
||||
);
|
||||
return CoursePackResponse.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 开始 / 继续攻克学习包
|
||||
Future<TaskDetail> startOvercome(String packId) async {
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/StudyPack/StartOvercome/$packId',
|
||||
);
|
||||
return TaskDetail.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/// ============================================================
|
||||
/// 【captureList 模块模型】攻克学习包 —— 对标 Vue3 端 api/task/type.ts
|
||||
///
|
||||
/// CoursePackResponse / CourseInfo / CourseRecord
|
||||
/// TaskDetail(开始/继续攻克返回)
|
||||
///
|
||||
/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 攻克记录(单次攻克的结果)
|
||||
class CourseRecord {
|
||||
const CourseRecord({
|
||||
this.id = 0,
|
||||
this.createTime = '',
|
||||
this.accuracyRate = 0,
|
||||
this.status = 0,
|
||||
this.statusName = '',
|
||||
this.overcomeTimeSecond = 0,
|
||||
});
|
||||
|
||||
final int id; // 记录 id
|
||||
final String createTime; // 创建时间
|
||||
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(
|
||||
id: _parseInt(json['id']),
|
||||
createTime: json['createTime'] as String? ?? '',
|
||||
accuracyRate: _parseInt(json['accuracyRate']),
|
||||
status: _parseInt(json['status']),
|
||||
statusName: json['statusName'] as String? ?? '',
|
||||
overcomeTimeSecond: _parseInt(json['overcomeTimeSecond']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个学习包(攻克包)信息
|
||||
class CourseInfo {
|
||||
const CourseInfo({
|
||||
this.id = 0,
|
||||
this.name = '',
|
||||
this.baseSubjectName = '',
|
||||
this.overcomeStandard = 0,
|
||||
this.estimatedTime = 0,
|
||||
this.maxOvercomeCount = 0,
|
||||
this.recordCount = 0,
|
||||
this.records = const [],
|
||||
this.status = 0,
|
||||
this.statusName = '',
|
||||
});
|
||||
|
||||
final int id; // 学习包 id
|
||||
final String name; // 名称
|
||||
final String baseSubjectName; // 学科名称
|
||||
final int overcomeStandard; // 攻克标准(百分比)
|
||||
final int estimatedTime; // 预计用时(分钟)
|
||||
final int maxOvercomeCount; // 最大可攻克次数
|
||||
final int recordCount; // 已攻克次数
|
||||
final List<CourseRecord> records; // 历次攻克记录
|
||||
final int status; // 状态:0 未开始 / 1 进行中 / 2 已攻克 / 3 失败 / 4 后续学习 / 5 判题中(按后端枚举)
|
||||
final String statusName; // 状态名称
|
||||
|
||||
factory CourseInfo.fromJson(Map<String, dynamic> json) {
|
||||
return CourseInfo(
|
||||
id: _parseInt(json['id']),
|
||||
name: json['name'] as String? ?? '',
|
||||
baseSubjectName: json['baseSubjectName'] as String? ?? '',
|
||||
overcomeStandard: _parseInt(json['overcomeStandard']),
|
||||
estimatedTime: _parseInt(json['estimatedTime']),
|
||||
maxOvercomeCount: _parseInt(json['maxOvercomeCount']),
|
||||
recordCount: _parseInt(json['recordCount']),
|
||||
records: (json['records'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(CourseRecord.fromJson)
|
||||
.toList(),
|
||||
status: _parseInt(json['status']),
|
||||
statusName: json['statusName'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 学习包列表响应(接口返回 {packs: [...], remark: '...'})
|
||||
class CoursePackResponse {
|
||||
const CoursePackResponse({
|
||||
this.packs = const [],
|
||||
this.remark = '',
|
||||
});
|
||||
|
||||
final List<CourseInfo> packs; // 学习包列表
|
||||
final String remark; // 规则说明文字
|
||||
|
||||
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
|
||||
factory CoursePackResponse.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return CoursePackResponse(
|
||||
packs: (payload['packs'] as List<dynamic>? ??
|
||||
payload['courses'] as List<dynamic>? ??
|
||||
const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(CourseInfo.fromJson)
|
||||
.toList(),
|
||||
remark: payload['remark'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始 / 继续攻克 接口返回(精简版,包含进入下一步所需字段)
|
||||
class TaskDetail {
|
||||
const TaskDetail({
|
||||
this.id = 0,
|
||||
this.name = '',
|
||||
this.overcomeStandard = 0,
|
||||
this.planTimeMinute = 0,
|
||||
this.recordId = 0,
|
||||
this.nowStudyObjectType = 0,
|
||||
this.nowStudyObjectId = 0,
|
||||
this.hasHandout = false,
|
||||
this.hasQuestion = false,
|
||||
});
|
||||
|
||||
final int id; // 学习包 id
|
||||
final String name; // 名称
|
||||
final int overcomeStandard; // 攻克标准
|
||||
final int planTimeMinute; // 计划用时(分钟)
|
||||
final int recordId; // 学习记录 id(跳转到习题/讲义页用)
|
||||
final int nowStudyObjectType; // 当前学习对象类型:0 讲义 / 11 习题 等
|
||||
final int nowStudyObjectId; // 当前学习对象 id
|
||||
final bool hasHandout; // 是否有讲义
|
||||
final bool hasQuestion; // 是否有题目
|
||||
|
||||
factory TaskDetail.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return TaskDetail(
|
||||
id: _parseInt(payload['id']),
|
||||
name: payload['name'] as String? ?? '',
|
||||
overcomeStandard: _parseInt(payload['overcomeStandard']),
|
||||
planTimeMinute: _parseInt(payload['planTimeMinute']),
|
||||
recordId: _parseInt(payload['recordId']),
|
||||
nowStudyObjectType: _parseInt(payload['nowStudyObjectType']),
|
||||
nowStudyObjectId: _parseInt(payload['nowStudyObjectId']),
|
||||
hasHandout: payload['hasHandout'] as bool? ?? false,
|
||||
hasQuestion: payload['hasQuestion'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解析 int:兼容数字和字符串数字,解析失败返回 0
|
||||
int _parseInt(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is int) return value;
|
||||
if (value is double) return value.toInt();
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:new_learn/core/api/exam/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【exam 模块接口】我的考试 —— 对标 Vue3 端 api/exam/index.ts
|
||||
///
|
||||
/// getSubjects —— 获取考试学科筛选 Tab 列表
|
||||
/// search —— 我的考试分页列表
|
||||
///
|
||||
/// 注意:考试报告详情(getReport)等考试做题相关接口,
|
||||
/// 后续扩展时再按模块拆分补进来。
|
||||
/// ============================================================
|
||||
class ExamApi {
|
||||
ExamApi._();
|
||||
|
||||
static final ExamApi instance = ExamApi._();
|
||||
|
||||
/// 考试学科筛选 Tab 列表
|
||||
/// GET /api/v1/Evaluation/GetSubjects
|
||||
Future<List<ExamSubjectTab>> getSubjects() async {
|
||||
final data = await DioClient.instance
|
||||
.get('/api/v1/Evaluation/GetSubjects');
|
||||
final list = data is List
|
||||
? data.whereType<Map<String, dynamic>>().toList()
|
||||
: (data is Map<String, dynamic>
|
||||
? (data['data'] as List<dynamic>? ?? const [])
|
||||
: const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
return list.map(ExamSubjectTab.fromJson).toList();
|
||||
}
|
||||
|
||||
/// 我的考试分页列表
|
||||
/// GET /api/v1/Evaluation/Search
|
||||
/// 参数:baseSubjectId(可 null=全部)/ pageIndex / pageSize
|
||||
Future<ExamResponse> search({
|
||||
int? baseSubjectId,
|
||||
required int pageIndex,
|
||||
int pageSize = 10,
|
||||
}) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Evaluation/Search',
|
||||
query: {
|
||||
if (baseSubjectId != null) 'baseSubjectId': baseSubjectId,
|
||||
'pageIndex': pageIndex,
|
||||
'pageSize': pageSize,
|
||||
},
|
||||
);
|
||||
return ExamResponse.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/// ============================================================
|
||||
/// 【exam 模块模型】我的考试 —— 对标 Vue3 端 api/exam/type.ts
|
||||
///
|
||||
/// ExamDto —— 单条考试记录
|
||||
/// ExamSubjectTab —— 考试科目筛选 Tab
|
||||
/// ExamResponse —— 考试列表分页响应
|
||||
///
|
||||
/// 序列化:手动 fromJson,与项目其他模块风格一致,不依赖 build_runner。
|
||||
///
|
||||
/// ⚠ 注意:后端返回的 id 字段类型不统一(数字 or 字符串),
|
||||
/// 所有数值字段统一用 _asInt() 做兼容解析,避免类型转换异常。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
int? _optInt(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
final s = v.toString();
|
||||
final n = int.tryParse(s);
|
||||
if (n != null) return n;
|
||||
return double.tryParse(s)?.toInt();
|
||||
}
|
||||
|
||||
int _asInt(dynamic v, [int fallback = 0]) => _optInt(v) ?? fallback;
|
||||
|
||||
/// 单条考试记录
|
||||
class ExamDto {
|
||||
const ExamDto({
|
||||
this.id = 0,
|
||||
this.taskId = 0,
|
||||
this.taskName = '',
|
||||
this.baseSubjectId = 0,
|
||||
this.baseSubjectName = '',
|
||||
this.startTime = '',
|
||||
this.endTime = '',
|
||||
this.totalScore = 0,
|
||||
this.userScore = 0,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int taskId;
|
||||
final String taskName;
|
||||
final int baseSubjectId;
|
||||
final String baseSubjectName;
|
||||
final String startTime;
|
||||
final String endTime;
|
||||
final int totalScore;
|
||||
final int userScore;
|
||||
|
||||
factory ExamDto.fromJson(Map<String, dynamic> json) {
|
||||
return ExamDto(
|
||||
id: _asInt(json['id']),
|
||||
taskId: _asInt(json['taskId']),
|
||||
taskName: (json['taskName'] as String?) ?? '',
|
||||
baseSubjectId: _asInt(json['baseSubjectId']),
|
||||
baseSubjectName: (json['baseSubjectName'] as String?) ?? '',
|
||||
startTime: (json['startTime'] as String?) ?? '',
|
||||
endTime: (json['endTime'] as String?) ?? '',
|
||||
totalScore: _asInt(json['totalScore']),
|
||||
userScore: _asInt(json['userScore']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 考试科目筛选 Tab
|
||||
class ExamSubjectTab {
|
||||
const ExamSubjectTab({this.id, this.name = ''});
|
||||
|
||||
/// 为 null 表示"全部"
|
||||
final int? id;
|
||||
final String name;
|
||||
|
||||
factory ExamSubjectTab.fromJson(Map<String, dynamic> json) {
|
||||
return ExamSubjectTab(
|
||||
id: _optInt(json['id']),
|
||||
name: (json['name'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 考试列表分页响应
|
||||
class ExamResponse {
|
||||
const ExamResponse({
|
||||
this.data = const [],
|
||||
this.total = 0,
|
||||
this.pageIndex = 1,
|
||||
this.pageSize = 10,
|
||||
});
|
||||
|
||||
final List<ExamDto> data;
|
||||
final int total;
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
|
||||
factory ExamResponse.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
final list = (payload['data'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ExamDto.fromJson)
|
||||
.toList();
|
||||
return ExamResponse(
|
||||
data: list,
|
||||
total: (payload['total'] as num?)?.toInt() ?? list.length,
|
||||
pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1,
|
||||
pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:new_learn/core/api/lexicon/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【lexicon 模块接口】背单词接口 —— 对标 Vue3 端 api/lexicon/index.ts
|
||||
///
|
||||
/// getChapters → GET GetChapters/{bookId} 章节列表
|
||||
/// getLevels → GET GetLevels/{chapterId} 关卡列表
|
||||
/// levelStart → POST LevelStart/{levelId} 开局取题
|
||||
/// answerQuestion → POST Answer 提交答案
|
||||
/// getLevelIsFinish → GET GetLevelIsFinish/{levelId} 关卡是否完成(轮询)
|
||||
/// getLevelOverInfo → GET GetLevelOverInfo/{userLevelId} 闯关小结
|
||||
///
|
||||
/// 注意:getChapters Vue 端带 hideErrorToast,toast 由页面自行处理;
|
||||
/// 异常统一向上抛,页面 catch 后决定提示。
|
||||
/// ============================================================
|
||||
class LexiconApi {
|
||||
LexiconApi._();
|
||||
|
||||
static final LexiconApi instance = LexiconApi._();
|
||||
|
||||
/// 后端响应壳 {code, message, data} 统一取 data(用于裸值响应)
|
||||
dynamic _unwrap(dynamic data) =>
|
||||
data is Map<String, dynamic> && data['data'] != null
|
||||
? data['data']
|
||||
: data;
|
||||
|
||||
/// 章节列表(model 内部兼容响应壳)
|
||||
Future<ChapterResponse> getChapters(int bookId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Lexicon/GetChapters/$bookId',
|
||||
);
|
||||
return ChapterResponse.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 关卡列表
|
||||
Future<LevelResponse> getLevels(int chapterId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Lexicon/GetLevels/$chapterId',
|
||||
);
|
||||
return LevelResponse.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 开局取题
|
||||
Future<LevelStartInfo> levelStart(int levelId) async {
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/Lexicon/LevelStart/$levelId',
|
||||
);
|
||||
return LevelStartInfo.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 提交答案(返回值与开局信息同构,但页面只关心成功与否)
|
||||
Future<bool> answerQuestion({
|
||||
required int levelId,
|
||||
required int userLevelId,
|
||||
required int lexiconId,
|
||||
required String answer,
|
||||
}) async {
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/Lexicon/Answer',
|
||||
data: {
|
||||
'levelId': levelId,
|
||||
'userLevelId': userLevelId,
|
||||
'lexiconId': lexiconId,
|
||||
'answer': answer,
|
||||
},
|
||||
);
|
||||
return data != null;
|
||||
}
|
||||
|
||||
/// 关卡是否完成(完关后后端有短暂处理延迟,需轮询)
|
||||
Future<bool> getLevelIsFinish(int levelId) async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.get('/api/v1/Lexicon/GetLevelIsFinish/$levelId'),
|
||||
);
|
||||
return data == true;
|
||||
}
|
||||
|
||||
/// 闯关小结
|
||||
Future<LevelOverInfo> getLevelOverInfo(int userLevelId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Lexicon/GetLevelOverInfo/$userLevelId',
|
||||
);
|
||||
return LevelOverInfo.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/// ============================================================
|
||||
/// 【lexicon 模块模型】背单词:章节 / 关卡 / 题目 / 小结
|
||||
/// —— 对标 Vue3 端 api/lexicon/type.ts
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 兼容 number | String 的 id 安全转换(对标 TS 的 number | string)
|
||||
int safeLexiconId(dynamic v) {
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
double _safeDouble(dynamic v) {
|
||||
if (v is num) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 章节(词汇章节列表项)
|
||||
class Chapter {
|
||||
const Chapter({
|
||||
this.id = 0,
|
||||
this.title = '',
|
||||
this.icon = '',
|
||||
this.finishLexiconCount = 0,
|
||||
this.levelCount = 0,
|
||||
this.lexiconCount = 0,
|
||||
this.finishProgress = 0,
|
||||
this.cover = '',
|
||||
this.index = '',
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String title;
|
||||
final String icon;
|
||||
final int finishLexiconCount;
|
||||
final int levelCount;
|
||||
final int lexiconCount;
|
||||
final double finishProgress; // 0~1
|
||||
final String cover;
|
||||
final String index; // 封面叠字序号(接口可能下发,Vue 端直接渲染 item.index)
|
||||
|
||||
factory Chapter.fromJson(Map<String, dynamic> json) {
|
||||
return Chapter(
|
||||
id: safeLexiconId(json['id']),
|
||||
title: json['title'] as String? ?? '',
|
||||
icon: json['icon'] as String? ?? '',
|
||||
finishLexiconCount: (json['finishLexiconCount'] as num?)?.toInt() ?? 0,
|
||||
levelCount: (json['levelCount'] as num?)?.toInt() ?? 0,
|
||||
lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0,
|
||||
finishProgress: _safeDouble(json['finishProgress']),
|
||||
cover: json['cover'] as String? ?? '',
|
||||
index: json['index']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节列表响应
|
||||
class ChapterResponse {
|
||||
const ChapterResponse({this.chapters = const [], this.totalFinishProgress = 0});
|
||||
|
||||
final List<Chapter> chapters;
|
||||
final double totalFinishProgress; // 0~1
|
||||
|
||||
factory ChapterResponse.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return ChapterResponse(
|
||||
chapters: (payload['chapters'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Chapter.fromJson)
|
||||
.toList(),
|
||||
totalFinishProgress: _safeDouble(payload['totalFinishProgress']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 关卡
|
||||
class Level {
|
||||
const Level({
|
||||
this.id = 0,
|
||||
this.title = '',
|
||||
this.lexiconCount = 0,
|
||||
this.isOpen = false,
|
||||
this.finishLexiconCount = 0,
|
||||
this.finishProgress = 0,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String title;
|
||||
final int lexiconCount;
|
||||
final bool isOpen; // 是否解锁
|
||||
final int finishLexiconCount;
|
||||
final double finishProgress; // 0~1
|
||||
|
||||
factory Level.fromJson(Map<String, dynamic> json) {
|
||||
return Level(
|
||||
id: safeLexiconId(json['id']),
|
||||
title: json['title'] as String? ?? '',
|
||||
lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0,
|
||||
isOpen: json['isOpen'] as bool? ?? false,
|
||||
finishLexiconCount: (json['finishLexiconCount'] as num?)?.toInt() ?? 0,
|
||||
finishProgress: _safeDouble(json['finishProgress']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 关卡列表响应(含章节头部汇总)
|
||||
class LevelResponse {
|
||||
const LevelResponse({
|
||||
this.id = 0,
|
||||
this.title = '',
|
||||
this.lexiconCount = 0,
|
||||
this.finishLexiconCount = 0,
|
||||
this.finishProgress = 0,
|
||||
this.levels = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String title;
|
||||
final int lexiconCount;
|
||||
final int finishLexiconCount;
|
||||
final double finishProgress;
|
||||
final List<Level> levels;
|
||||
|
||||
factory LevelResponse.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return LevelResponse(
|
||||
id: safeLexiconId(payload['id']),
|
||||
title: payload['title'] as String? ?? '',
|
||||
lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0,
|
||||
finishLexiconCount:
|
||||
(payload['finishLexiconCount'] as num?)?.toInt() ?? 0,
|
||||
finishProgress: _safeDouble(payload['finishProgress']),
|
||||
levels: (payload['levels'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Level.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 答题页单词题目
|
||||
class Question {
|
||||
const Question({
|
||||
this.id = 0,
|
||||
this.americanEnglish = '',
|
||||
this.americanPhonetic = '',
|
||||
this.americanVoiceUrl = '',
|
||||
this.imageDesc = '',
|
||||
this.contextMatch = '',
|
||||
this.english = '',
|
||||
this.phonetic = '',
|
||||
this.voiceUrl = '',
|
||||
this.chineseDesc = '',
|
||||
this.options = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String americanEnglish;
|
||||
final String americanPhonetic;
|
||||
final String americanVoiceUrl;
|
||||
final String imageDesc; // 配图
|
||||
final String contextMatch; // 语境搭配(空则不可翻转)
|
||||
final String english;
|
||||
final String phonetic;
|
||||
final String voiceUrl;
|
||||
final String chineseDesc; // 正确答案
|
||||
final List<String> options;
|
||||
|
||||
factory Question.fromJson(Map<String, dynamic> json) {
|
||||
return Question(
|
||||
id: safeLexiconId(json['id']),
|
||||
americanEnglish: json['americanEnglish'] as String? ?? '',
|
||||
americanPhonetic: json['americanPhonetic'] as String? ?? '',
|
||||
americanVoiceUrl: json['americanVoiceUrl'] as String? ?? '',
|
||||
imageDesc: json['imageDesc'] as String? ?? '',
|
||||
contextMatch: json['contextMatch'] as String? ?? '',
|
||||
english: json['english'] as String? ?? '',
|
||||
phonetic: json['phonetic'] as String? ?? '',
|
||||
voiceUrl: json['voiceUrl'] as String? ?? '',
|
||||
chineseDesc: json['chineseDesc'] as String? ?? '',
|
||||
options: (json['options'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 已作答单词(小结回顾用)
|
||||
class Lexicon {
|
||||
const Lexicon({
|
||||
this.id = 0,
|
||||
this.english = '',
|
||||
this.phonetic = '',
|
||||
this.voiceUrl = '',
|
||||
this.chineseDesc = '',
|
||||
this.isCorrect = false,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String english;
|
||||
final String phonetic;
|
||||
final String voiceUrl;
|
||||
final String chineseDesc;
|
||||
final bool isCorrect;
|
||||
|
||||
factory Lexicon.fromJson(Map<String, dynamic> json) {
|
||||
return Lexicon(
|
||||
id: safeLexiconId(json['id']),
|
||||
english: json['english'] as String? ?? '',
|
||||
phonetic: json['phonetic'] as String? ?? '',
|
||||
voiceUrl: json['voiceUrl'] as String? ?? '',
|
||||
chineseDesc: json['chineseDesc'] as String? ?? '',
|
||||
isCorrect: json['isCorrect'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// LevelStart 开局信息
|
||||
class LevelStartInfo {
|
||||
const LevelStartInfo({
|
||||
this.id = 0,
|
||||
this.userLevelId = 0,
|
||||
this.finishLexiconCount = 0,
|
||||
this.lexiconCount = 0,
|
||||
this.questions = const [],
|
||||
this.lexicons = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int userLevelId;
|
||||
final int finishLexiconCount;
|
||||
final int lexiconCount;
|
||||
final List<Question> questions;
|
||||
final List<Lexicon> lexicons;
|
||||
|
||||
factory LevelStartInfo.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return LevelStartInfo(
|
||||
id: safeLexiconId(payload['id']),
|
||||
userLevelId: (payload['userLevelId'] as num?)?.toInt() ?? 0,
|
||||
finishLexiconCount:
|
||||
(payload['finishLexiconCount'] as num?)?.toInt() ?? 0,
|
||||
lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0,
|
||||
questions: (payload['questions'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Question.fromJson)
|
||||
.toList(),
|
||||
lexicons: (payload['lexicons'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Lexicon.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 闯关小结信息
|
||||
class LevelOverInfo {
|
||||
const LevelOverInfo({
|
||||
this.id = 0,
|
||||
this.userLevelId = 0,
|
||||
this.lexiconCount = 0,
|
||||
this.correctCount = 0,
|
||||
this.wrongCount = 0,
|
||||
this.nextLevelId = 0,
|
||||
this.nextChapterId = 0,
|
||||
this.lexicons = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int userLevelId;
|
||||
final int lexiconCount;
|
||||
final int correctCount;
|
||||
final int wrongCount;
|
||||
final int nextLevelId; // 0 表示没有下一关
|
||||
final int nextChapterId; // 0 表示没有下一章
|
||||
final List<Lexicon> lexicons;
|
||||
|
||||
/// 正确率(0~100,四舍五入;无单词兜底 0)
|
||||
int get rate =>
|
||||
lexiconCount <= 0 ? 0 : (correctCount * 100 / lexiconCount).round();
|
||||
|
||||
factory LevelOverInfo.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return LevelOverInfo(
|
||||
id: safeLexiconId(payload['id']),
|
||||
userLevelId: (payload['userLevelId'] as num?)?.toInt() ?? 0,
|
||||
lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0,
|
||||
correctCount: (payload['correctCount'] as num?)?.toInt() ?? 0,
|
||||
wrongCount: (payload['wrongCount'] as num?)?.toInt() ?? 0,
|
||||
nextLevelId: safeLexiconId(payload['nextLevelId']),
|
||||
nextChapterId: safeLexiconId(payload['nextChapterId']),
|
||||
lexicons: (payload['lexicons'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Lexicon.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:new_learn/core/api/lexicon_book/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【lexicon_book 模块接口】词书接口 —— 对标 Vue3 端 api/lexiconBook/index.ts
|
||||
///
|
||||
/// getCurrentBook → GET GetCurrentLexiconBooks 当前学习的词书
|
||||
/// getLearnDayCount→ GET GetLearnDayCount 坚持学习天数
|
||||
/// getAllBooks → GET GetAllLexiconBooks 我的书库
|
||||
/// startLearn → POST StartLearn/{bookId} 切换/开始学习某书
|
||||
///
|
||||
/// 说明:Vue 端这 4 个接口都带 hideErrorToast(静默失败),
|
||||
/// 异常不在此层吞,交页面按需要 catch 空处理。
|
||||
/// ============================================================
|
||||
class LexiconBookApi {
|
||||
LexiconBookApi._();
|
||||
|
||||
static final LexiconBookApi instance = LexiconBookApi._();
|
||||
|
||||
/// 后端响应壳 {code, message, data} 统一取 data
|
||||
dynamic _unwrap(dynamic data) =>
|
||||
data is Map<String, dynamic> && data['data'] != null
|
||||
? data['data']
|
||||
: data;
|
||||
|
||||
/// 当前学习的词书(未选择时后端可能返回 null)
|
||||
Future<BookInfo?> getCurrentBook() async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.get('/api/v1/LexiconBook/GetCurrentLexiconBooks'),
|
||||
);
|
||||
if (data is Map<String, dynamic>) return BookInfo.fromJson(data);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 坚持学习天数
|
||||
Future<int> getLearnDayCount() async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.get('/api/v1/LexiconBook/GetLearnDayCount'),
|
||||
);
|
||||
if (data is num) return data.toInt();
|
||||
if (data is String) return int.tryParse(data) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 我的书库全部词书
|
||||
Future<List<BookInfo>> getAllBooks() async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.get('/api/v1/LexiconBook/GetAllLexiconBooks'),
|
||||
);
|
||||
if (data is List) {
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(BookInfo.fromJson)
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// 切换 / 开始学习某书
|
||||
Future<bool> startLearn(int bookId) async {
|
||||
final data = _unwrap(
|
||||
await DioClient.instance.post(
|
||||
'/api/v1/LexiconBook/StartLearn/$bookId',
|
||||
),
|
||||
);
|
||||
return data == true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/// ============================================================
|
||||
/// 【lexicon_book 模块模型】词书实体 —— 对标 Vue3 端 api/lexiconBook/type.ts
|
||||
///
|
||||
/// BookInfo:词书(背诵首页当前书 / 教材列表我的书库)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 兼容 number | String 的 id 安全转换(对标 TS 的 number | string)
|
||||
int _safeId(dynamic v) {
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 词书信息
|
||||
class BookInfo {
|
||||
const BookInfo({
|
||||
this.id = 0,
|
||||
this.title = '',
|
||||
this.cover = '',
|
||||
this.poster = '',
|
||||
this.slogan = '',
|
||||
this.lexiconCount = 0,
|
||||
this.totalCount = 0,
|
||||
this.isStudying = false,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String title; // 书名
|
||||
final String cover; // 封面图
|
||||
final String poster; // 顶部海报图
|
||||
final String slogan; // 海报标语
|
||||
final int lexiconCount; // 已学词数
|
||||
final int totalCount; // 总词数
|
||||
final bool isStudying; // 是否学习中
|
||||
|
||||
/// 学习进度百分比(0~100,取整;totalCount 为 0 时兜底 0)
|
||||
int get progressPercent =>
|
||||
totalCount <= 0 ? 0 : (lexiconCount * 100 ~/ totalCount);
|
||||
|
||||
factory BookInfo.fromJson(Map<String, dynamic> json) {
|
||||
return BookInfo(
|
||||
id: _safeId(json['id']),
|
||||
title: json['title'] as String? ?? '',
|
||||
cover: json['cover'] as String? ?? '',
|
||||
poster: json['poster'] as String? ?? '',
|
||||
slogan: json['slogan'] as String? ?? '',
|
||||
lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0,
|
||||
totalCount: (json['totalCount'] as num?)?.toInt() ?? 0,
|
||||
isStudying: json['isStudying'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:new_learn/core/api/my_report/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【my_report 模块接口】我的报告 —— 对标 Vue3 端 api/myReport/index.ts
|
||||
///
|
||||
/// getReportPage —— 报告列表分页(页码分页)
|
||||
/// GET /api/v1/UserReport/GetUserReportList
|
||||
/// 参数:pageIndex / pageSize / reportType(可选)
|
||||
/// ============================================================
|
||||
class UserReportApi {
|
||||
UserReportApi._();
|
||||
|
||||
static final UserReportApi instance = UserReportApi._();
|
||||
|
||||
/// 报告列表分页(页码分页:pageIndex / pageSize)
|
||||
/// [reportType] 可选筛选条件,Vue 端默认不传
|
||||
Future<UserReportPage> getReportPage({
|
||||
int pageIndex = 1,
|
||||
int pageSize = 10,
|
||||
int? reportType,
|
||||
}) async {
|
||||
final query = <String, dynamic>{
|
||||
'pageIndex': pageIndex,
|
||||
'pageSize': pageSize,
|
||||
};
|
||||
if (reportType != null) query['reportType'] = reportType;
|
||||
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserReport/GetUserReportList',
|
||||
query: query,
|
||||
);
|
||||
return UserReportPage.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/// ============================================================
|
||||
/// 【my_report 模块模型】我的报告 —— 对标 Vue3 端 api/myReport/type.ts
|
||||
///
|
||||
/// UserReportDto(单条报告)/ UserReportPage(分页响应)
|
||||
///
|
||||
/// 字段说明:
|
||||
/// id 报告 ID(后端大整数,返回 string 类型,前端也用 String 存)
|
||||
/// reportName 报告名称(如"数学分析报告")
|
||||
/// reportType 报告类型
|
||||
/// createTime 创建时间字符串(如 "2026-08-28 15:24:39" 或 ISO "T" 分隔)
|
||||
/// reportUrl 报告详情 H5 页面 URL(点击列表项后 WebView 加载这个)
|
||||
///
|
||||
/// 序列化:手动 fromJson,做了类型兼容(后端 id 有时 string 有时 number,
|
||||
/// 统一转 String;reportUrl 清理首尾空白和反引号)。
|
||||
/// 与 my_point/model.dart 保持一致,不依赖 build_runner。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 辅助:id 字段兼容 string / num 两种后端返回,统一转 String
|
||||
String _asStringId(dynamic value) {
|
||||
if (value == null) return '';
|
||||
if (value is String) return value.trim();
|
||||
if (value is num) return value.toInt().toString();
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/// 辅助:清理 reportUrl 首尾可能的反引号 / 空白(后端可能包了一层引号)
|
||||
String _cleanUrl(String raw) {
|
||||
final cleaned = raw.trim();
|
||||
// 去掉首尾反引号(用户粘贴时可能加的,或后端真返回了)
|
||||
if (cleaned.startsWith('`') && cleaned.endsWith('`')) {
|
||||
return cleaned.substring(1, cleaned.length - 1).trim();
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// 单条报告
|
||||
class UserReportDto {
|
||||
const UserReportDto({
|
||||
this.id = '',
|
||||
this.reportName = '',
|
||||
this.reportType = 0,
|
||||
this.createTime = '',
|
||||
this.reportUrl = '',
|
||||
});
|
||||
|
||||
final String id; // 后端大整数 ID,用 String 存避免精度丢失
|
||||
final String reportName;
|
||||
final int reportType;
|
||||
final String createTime;
|
||||
final String reportUrl;
|
||||
|
||||
factory UserReportDto.fromJson(Map<String, dynamic> json) {
|
||||
return UserReportDto(
|
||||
id: _asStringId(json['id']),
|
||||
reportName: (json['reportName'] as String?) ?? '',
|
||||
reportType: (json['reportType'] as num?)?.toInt() ?? 0,
|
||||
createTime: (json['createTime'] as String?) ?? '',
|
||||
reportUrl: _cleanUrl((json['reportUrl'] as String?) ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 报告分页响应
|
||||
class UserReportPage {
|
||||
const UserReportPage({
|
||||
this.data = const [],
|
||||
this.total = 0,
|
||||
this.pageIndex = 1,
|
||||
this.pageSize = 10,
|
||||
});
|
||||
|
||||
final List<UserReportDto> data;
|
||||
final int total;
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
|
||||
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
|
||||
factory UserReportPage.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return UserReportPage(
|
||||
data: (payload['data'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserReportDto.fromJson)
|
||||
.toList(),
|
||||
total: (payload['total'] as num?)?.toInt() ?? 0,
|
||||
pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1,
|
||||
pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【笔记相关接口】讲义笔记 / 试题笔记
|
||||
///
|
||||
/// 对应 Vue 端 api/task/index.ts:
|
||||
/// HandoutNoteAdd → POST /api/v1/HandoutNote/Add
|
||||
/// HandoutNoteDel → DELETE /api/v1/HandoutNote/Del/{id}
|
||||
/// QuestionNoteAdd → POST /api/v1/QuestionNote/Add
|
||||
/// QuestionNoteDel → DELETE /api/v1/QuestionNote/Del/{id}
|
||||
///
|
||||
/// 注意:页面只调这里,不直接碰 Dio;异常不在这里 catch,交页面统一处理。
|
||||
/// ============================================================
|
||||
class NoteApi {
|
||||
NoteApi._();
|
||||
|
||||
static final NoteApi instance = NoteApi._();
|
||||
|
||||
/// 添加讲义笔记 → POST /api/v1/HandoutNote/Add
|
||||
///
|
||||
/// [id] 讲义 id
|
||||
/// [url] 原图 URL(OSS 直传后返回)
|
||||
/// [thumbnailUrl] 缩略图 URL
|
||||
Future<NoteImage> handoutNoteAdd({
|
||||
required dynamic id,
|
||||
required String url,
|
||||
required String thumbnailUrl,
|
||||
}) async {
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/HandoutNote/Add',
|
||||
data: {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
},
|
||||
);
|
||||
// 后端统一 {code, message, data} 包装:data 存在取 data,否则用顶层
|
||||
final payload =
|
||||
(data is Map<String, dynamic> && data.containsKey('data'))
|
||||
? (data['data'] as Map<String, dynamic>?) ?? <String, dynamic>{}
|
||||
: data as Map<String, dynamic>;
|
||||
return NoteImage.fromJson(payload);
|
||||
}
|
||||
|
||||
/// 删除讲义笔记 → DELETE /api/v1/HandoutNote/Del/{id}
|
||||
///
|
||||
/// 返回 true 表示删除成功
|
||||
Future<bool> handoutNoteDel(dynamic id) async {
|
||||
await DioClient.instance.delete('/api/v1/HandoutNote/Del/$id');
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 添加试题笔记 → POST /api/v1/QuestionNote/Add
|
||||
Future<NoteImage> questionNoteAdd({
|
||||
required dynamic id,
|
||||
required String url,
|
||||
required String thumbnailUrl,
|
||||
}) async {
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/QuestionNote/Add',
|
||||
data: {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
},
|
||||
);
|
||||
final payload =
|
||||
(data is Map<String, dynamic> && data.containsKey('data'))
|
||||
? (data['data'] as Map<String, dynamic>?) ?? <String, dynamic>{}
|
||||
: data as Map<String, dynamic>;
|
||||
return NoteImage.fromJson(payload);
|
||||
}
|
||||
|
||||
/// 删除试题笔记 → DELETE /api/v1/QuestionNote/Del/{id}
|
||||
Future<bool> questionNoteDel(dynamic id) async {
|
||||
await DioClient.instance.delete('/api/v1/QuestionNote/Del/$id');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/// ============================================================
|
||||
/// 【笔记图片数据模型】讲义笔记 / 试题笔记
|
||||
///
|
||||
/// NoteImage 对应 Vue 端上传笔记后返回的图片记录:
|
||||
/// - id:笔记记录 id(删除接口用)
|
||||
/// - url:原图 URL
|
||||
/// - thumbnailUrl:缩略图 URL(列表预览用)
|
||||
/// ============================================================
|
||||
|
||||
/// 笔记图片项(用于已上传图片列表)
|
||||
class NoteImage {
|
||||
const NoteImage({
|
||||
this.id,
|
||||
required this.url,
|
||||
this.thumbnailUrl,
|
||||
});
|
||||
|
||||
/// 服务器返回的笔记 id(删除接口用;本地添加的图片可能没有)
|
||||
final dynamic id;
|
||||
|
||||
/// 图片原图 URL
|
||||
final String url;
|
||||
|
||||
/// 缩略图 URL(为空时用 url 兜底)
|
||||
final String? thumbnailUrl;
|
||||
|
||||
factory NoteImage.fromJson(Map<String, dynamic> json) {
|
||||
return NoteImage(
|
||||
id: json['id'],
|
||||
url: (json['url'] as String?) ?? '',
|
||||
thumbnailUrl: json['thumbnailUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:new_learn/core/api/report/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【report 模块接口】入门测/出门测诊断报告 —— 对标 Vue3 端 api/report/index.ts
|
||||
///
|
||||
/// 注意:这个模块是"诊断报告包"那条链路的接口(studentInfo / reportComputation 页用),
|
||||
/// 与 core/api/my_report(历史报告列表)不是同一个业务模块。
|
||||
///
|
||||
/// getCurrentReport() → 入门测报告(placementTest)
|
||||
/// getCurrentAfterReport() → 出门测报告(exitTest)
|
||||
/// getCurrentReportKnow() → 当前用户未掌握知识点(reportComputation AI 动画页用)
|
||||
/// ============================================================
|
||||
class ReportApi {
|
||||
ReportApi._();
|
||||
|
||||
static final ReportApi instance = ReportApi._();
|
||||
|
||||
/// 获取当前用户入门测试报告 → GET /api/v1/UserReport/GetCurrentReport
|
||||
Future<UserReportType> getCurrentReport() async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserReport/GetCurrentReport',
|
||||
);
|
||||
return UserReportType.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 获取当前用户出门测试报告 → GET /api/v1/UserReport/GetCurrentAfterReport
|
||||
Future<UserReportType> getCurrentAfterReport() async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserReport/GetCurrentAfterReport',
|
||||
);
|
||||
return UserReportType.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 获取当前用户未掌握的知识点 → GET /api/v1/UserReport/GetCurrentReportKnow
|
||||
Future<List<UserReportKnowType>> getCurrentReportKnow() async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserReport/GetCurrentReportKnow',
|
||||
);
|
||||
final list = data is List ? data : (data['data'] as List<dynamic>? ?? const []);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserReportKnowType.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/// ============================================================
|
||||
/// 【report 模块模型】入门测/出门测报告 —— 对标 Vue3 端 api/report/type.ts
|
||||
///
|
||||
/// 注意:这个模块是"诊断报告包"(报告状态流转那条链路用的),
|
||||
/// 与 core/api/my_report(历史报告列表)不是同一个业务模块。
|
||||
///
|
||||
/// SubjectReportType → 单科目报告(科目名/图标/知识点数/报告URL)
|
||||
/// UserReportType → 当前用户诊断报告整体(用户名/能力等级/科目列表)
|
||||
/// UserReportKnowType → 当前用户未掌握的知识点(reportComputation 页用)
|
||||
///
|
||||
/// 序列化:手动 fromJson,与项目其他模块风格一致,不依赖 build_runner。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 单科目报告信息
|
||||
class SubjectReportType {
|
||||
const SubjectReportType({
|
||||
this.subjectName = '',
|
||||
this.url = '',
|
||||
this.icon = '',
|
||||
this.knowledgePoint = 0,
|
||||
});
|
||||
|
||||
final String subjectName;
|
||||
final String url;
|
||||
final String icon;
|
||||
final int knowledgePoint;
|
||||
|
||||
factory SubjectReportType.fromJson(Map<String, dynamic> json) {
|
||||
return SubjectReportType(
|
||||
subjectName: (json['subjectName'] as String?) ?? '',
|
||||
url: (json['url'] as String?) ?? '',
|
||||
icon: (json['icon'] as String?) ?? '',
|
||||
knowledgePoint: (json['knowledgePoint'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前用户诊断报告整体
|
||||
class UserReportType {
|
||||
const UserReportType({
|
||||
this.userName = '',
|
||||
this.userLevel = '',
|
||||
this.subjectReports = const [],
|
||||
});
|
||||
|
||||
final String userName;
|
||||
final String userLevel;
|
||||
final List<SubjectReportType> subjectReports;
|
||||
|
||||
factory UserReportType.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return UserReportType(
|
||||
userName: (payload['userName'] as String?) ?? '',
|
||||
userLevel: (payload['userLevel'] as String?) ?? '',
|
||||
subjectReports: (payload['subjectReports'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SubjectReportType.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前用户未掌握的知识点(reportComputation AI加载动画页用)
|
||||
class UserReportKnowType {
|
||||
const UserReportKnowType({
|
||||
this.subjectName = '',
|
||||
this.knowCount = 0,
|
||||
});
|
||||
|
||||
final String subjectName;
|
||||
final int knowCount;
|
||||
|
||||
factory UserReportKnowType.fromJson(Map<String, dynamic> json) {
|
||||
return UserReportKnowType(
|
||||
subjectName: (json['subjectName'] as String?) ?? '',
|
||||
knowCount: (json['knowCount'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:new_learn/core/api/user_collect/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【user_collect 模块接口】我的收藏 + 错题本 —— 对标 Vue3 端 api/userCollect/index.ts
|
||||
///
|
||||
/// getSubjects —— 学科筛选项(顶部 Tab)
|
||||
/// GET /api/v1/UserCollect/GetUserCollectSubject/{type}
|
||||
///
|
||||
/// getCollectList —— 收藏/错题分页列表(游标分页)
|
||||
/// GET /api/v1/UserCollect/GetUserCollect
|
||||
/// 参数:type(1收藏/2错题) / subjectId(可选) / pageIndex / pageSize / prevPageId
|
||||
///
|
||||
/// 游标分页说明(对齐 Vue useList):
|
||||
/// 第一页 prevPageId=0,之后每页传上一页最后一条的 id;
|
||||
/// 切学科/下拉刷新时 prevPageId 重置为 0。
|
||||
/// ============================================================
|
||||
class UserCollectApi {
|
||||
UserCollectApi._();
|
||||
|
||||
static final UserCollectApi instance = UserCollectApi._();
|
||||
|
||||
/// 学科筛选项([type] 1=我的收藏 2=错题本)
|
||||
Future<List<CollectTab>> getSubjects(int type) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserCollect/GetUserCollectSubject/$type',
|
||||
);
|
||||
// 兼容裸数组 / {data: [...]} 响应壳
|
||||
final List<dynamic> list = data is List
|
||||
? data
|
||||
: (data is Map<String, dynamic>
|
||||
? (data['data'] as List<dynamic>? ?? const [])
|
||||
: const []);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(CollectTab.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 收藏/错题分页列表
|
||||
///
|
||||
/// [type] 1=我的收藏 2=错题本;[subjectId] 不传 = 全部学科;
|
||||
/// [prevPageId] 游标:首页传 0,翻页传上一页最后一条 id。
|
||||
Future<UserCollectPage> getCollectList({
|
||||
required int type,
|
||||
int? subjectId,
|
||||
required int pageIndex,
|
||||
int pageSize = 10,
|
||||
int prevPageId = 0,
|
||||
}) async {
|
||||
final query = <String, dynamic>{
|
||||
'type': type,
|
||||
'pageIndex': pageIndex,
|
||||
'pageSize': pageSize,
|
||||
'prevPageId': prevPageId,
|
||||
};
|
||||
// 对齐 Vue useList:subjectId 为 null 时不带该参数
|
||||
if (subjectId != null) query['subjectId'] = subjectId;
|
||||
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserCollect/GetUserCollect',
|
||||
query: query,
|
||||
);
|
||||
return UserCollectPage.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/// ============================================================
|
||||
/// 【user_collect 模块模型】我的收藏 + 错题本 —— 对标 Vue3 端 api/userCollect/type.ts
|
||||
///
|
||||
/// CollectTab 学科筛选 Tab(GetUserCollectSubject 返回项)
|
||||
/// UserCollectItem 列表项(收藏/错题通用,靠 type 区分)
|
||||
/// UserCollectPage 分页响应(游标分页:pageIndex + prevPageId)
|
||||
///
|
||||
/// 说明:错题本页(myWrong)与收藏页(myCollect)共用同一套接口,
|
||||
/// type=1 我的收藏 / type=2 错题本。
|
||||
/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳),
|
||||
/// 不依赖 build_runner。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失),
|
||||
/// 也可能直接是 num;统一转 int,无法解析返回 null。
|
||||
int? _toInt(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String && v.isNotEmpty) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 学科筛选 Tab
|
||||
class CollectTab {
|
||||
const CollectTab({
|
||||
this.id,
|
||||
required this.name,
|
||||
this.remark = '',
|
||||
this.status = 0,
|
||||
});
|
||||
|
||||
/// 学科 id;null = 客户端插入的"全部"
|
||||
final int? id;
|
||||
final String name;
|
||||
final String remark;
|
||||
final int status;
|
||||
|
||||
factory CollectTab.fromJson(Map<String, dynamic> json) {
|
||||
return CollectTab(
|
||||
id: _toInt(json['id']),
|
||||
name: json['name'] as String? ?? '',
|
||||
remark: json['remark'] as String? ?? '',
|
||||
status: _toInt(json['status']) ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 收藏/错题列表项
|
||||
class UserCollectItem {
|
||||
const UserCollectItem({
|
||||
this.id = 0,
|
||||
this.questionId = 0,
|
||||
this.recordQuestionId = 0,
|
||||
this.questionStem = '',
|
||||
this.subjectName = '',
|
||||
this.packName = '',
|
||||
this.createTime = '',
|
||||
});
|
||||
|
||||
/// 列表明细 id(游标分页 prevPageId 取本页最后一条的 id)
|
||||
final int id;
|
||||
|
||||
/// 题目 id(详情页参数 questionId)
|
||||
final int questionId;
|
||||
|
||||
/// 答题记录 id(详情页参数 recordQuestionId)
|
||||
final int recordQuestionId;
|
||||
|
||||
/// 题干 HTML(RichTextContent 渲染)
|
||||
final String questionStem;
|
||||
|
||||
/// 学科名(SubjectTag 配色用)
|
||||
final String subjectName;
|
||||
|
||||
/// 来源试卷/礼包名
|
||||
final String packName;
|
||||
|
||||
/// 收藏/错题时间字符串
|
||||
final String createTime;
|
||||
|
||||
factory UserCollectItem.fromJson(Map<String, dynamic> json) {
|
||||
return UserCollectItem(
|
||||
id: _toInt(json['id']) ?? 0,
|
||||
questionId: _toInt(json['questionId']) ?? 0,
|
||||
recordQuestionId: _toInt(json['recordQuestionId']) ?? 0,
|
||||
questionStem: json['questionStem'] as String? ?? '',
|
||||
subjectName: json['subjectName'] as String? ?? '',
|
||||
packName: json['packName'] as String? ?? '',
|
||||
createTime: json['createTime'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 收藏/错题分页响应
|
||||
class UserCollectPage {
|
||||
const UserCollectPage({
|
||||
this.data = const [],
|
||||
this.total = 0,
|
||||
this.pageIndex = 1,
|
||||
this.pageSize = 10,
|
||||
});
|
||||
|
||||
final List<UserCollectItem> data; // 当前页数据
|
||||
final int total; // 总条数(顶部"X 道错题/收藏题目")
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
|
||||
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
|
||||
factory UserCollectPage.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] is Map<String, dynamic>)
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
return UserCollectPage(
|
||||
data: (payload['data'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserCollectItem.fromJson)
|
||||
.toList(),
|
||||
total: _toInt(payload['total']) ?? 0,
|
||||
pageIndex: _toInt(payload['pageIndex']) ?? 1,
|
||||
pageSize: _toInt(payload['pageSize']) ?? 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:new_learn/core/api/user_collect/model.dart';
|
||||
import 'package:new_learn/core/api/user_note/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【user_note 模块接口】我的笔记 —— 对标 Vue3 端 api/userNote/index.ts
|
||||
///
|
||||
/// getSubjects —— 学科筛选项(顶部 Tab,无参)
|
||||
/// GET /api/v1/UserNote/GetUserCollectSubject
|
||||
///
|
||||
/// getNoteList —— 笔记分页列表(游标分页)
|
||||
/// GET /api/v1/UserNote/GetUserNotes
|
||||
/// 参数:subjectId(可选) / pageIndex / pageSize / prevPageId
|
||||
///
|
||||
/// 列表不按笔记类型过滤;type(1题目/2讲义) 由每条数据自带,
|
||||
/// 前端用卡片角标图标区分。
|
||||
///
|
||||
/// 注意:Vue 同文件里的 OSS 凭证/上传地址接口归属 OSS 模块
|
||||
/// (Flutter 端见 core/network/oss_api.dart),不在此重复封装。
|
||||
/// ============================================================
|
||||
class UserNoteApi {
|
||||
UserNoteApi._();
|
||||
|
||||
static final UserNoteApi instance = UserNoteApi._();
|
||||
|
||||
/// 学科筛选项
|
||||
Future<List<CollectTab>> getSubjects() async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserNote/GetUserCollectSubject',
|
||||
);
|
||||
// 兼容裸数组 / {data: [...]} 响应壳
|
||||
final List<dynamic> list = data is List
|
||||
? data
|
||||
: (data is Map<String, dynamic>
|
||||
? (data['data'] as List<dynamic>? ?? const [])
|
||||
: const []);
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(CollectTab.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 笔记分页列表
|
||||
///
|
||||
/// [subjectId] 不传 = 全部学科;[prevPageId] 游标:首页传 0,
|
||||
/// 翻页传上一页最后一条 id。
|
||||
Future<UserNotePage> getNoteList({
|
||||
int? subjectId,
|
||||
required int pageIndex,
|
||||
int pageSize = 10,
|
||||
int prevPageId = 0,
|
||||
}) async {
|
||||
final query = <String, dynamic>{
|
||||
'pageIndex': pageIndex,
|
||||
'pageSize': pageSize,
|
||||
'prevPageId': prevPageId,
|
||||
};
|
||||
// 对齐 Vue useList:subjectId 为 null 时不带该参数
|
||||
if (subjectId != null) query['subjectId'] = subjectId;
|
||||
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserNote/GetUserNotes',
|
||||
query: query,
|
||||
);
|
||||
return UserNotePage.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/// ============================================================
|
||||
/// 【user_note 模块模型】我的笔记 —— 对标 Vue3 端 api/userNote/type.ts
|
||||
///
|
||||
/// UserNoteItem 笔记列表项(type 1=题目笔记 2=讲义笔记)
|
||||
/// UserNotePage 分页响应(游标分页:pageIndex + prevPageId)
|
||||
///
|
||||
/// 学科筛选 Tab 复用收藏模块的 CollectTab(见 api.dart 的 import,
|
||||
/// 与 Vue 端 api/userNote/index.ts 从 ../userCollect/type 引入一致)。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失)
|
||||
int? _noteToInt(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String && v.isNotEmpty) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 我的笔记列表项
|
||||
class UserNoteItem {
|
||||
const UserNoteItem({
|
||||
this.id = 0,
|
||||
this.subjectName = '',
|
||||
this.type = 0,
|
||||
this.title = '',
|
||||
this.content = '',
|
||||
this.createTime = '',
|
||||
});
|
||||
|
||||
/// 笔记 id(游标分页 prevPageId 取本页最后一条的 id;详情页参数 id)
|
||||
final int id;
|
||||
|
||||
/// 学科名(SubjectTag 配色用)
|
||||
final String subjectName;
|
||||
|
||||
/// 笔记类型:1=题目笔记 2=讲义笔记(卡片角标/详情页区分用)
|
||||
final int type;
|
||||
|
||||
/// 笔记标题
|
||||
final String title;
|
||||
|
||||
/// 笔记内容 HTML(RichTextContent 渲染)
|
||||
final String content;
|
||||
|
||||
/// 记笔记时间字符串
|
||||
final String createTime;
|
||||
|
||||
factory UserNoteItem.fromJson(Map<String, dynamic> json) {
|
||||
return UserNoteItem(
|
||||
id: _noteToInt(json['id']) ?? 0,
|
||||
subjectName: json['subjectName'] as String? ?? '',
|
||||
type: _noteToInt(json['type']) ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
createTime: json['createTime'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的笔记分页响应
|
||||
class UserNotePage {
|
||||
const UserNotePage({
|
||||
this.data = const [],
|
||||
this.total = 0,
|
||||
this.pageIndex = 1,
|
||||
this.pageSize = 10,
|
||||
});
|
||||
|
||||
final List<UserNoteItem> data;
|
||||
final int total;
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
|
||||
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
|
||||
factory UserNotePage.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] is Map<String, dynamic>)
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
return UserNotePage(
|
||||
data: (payload['data'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserNoteItem.fromJson)
|
||||
.toList(),
|
||||
total: _noteToInt(payload['total']) ?? 0,
|
||||
pageIndex: _noteToInt(payload['pageIndex']) ?? 1,
|
||||
pageSize: _noteToInt(payload['pageSize']) ?? 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -9,6 +9,7 @@ import 'package:new_learn/core/config/app_config.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/providers/network_status_provider.dart';
|
||||
import 'package:new_learn/core/providers/privacy_provider.dart';
|
||||
import 'package:new_learn/core/report/report_redirect.dart';
|
||||
import 'package:new_learn/core/router/app_router.dart';
|
||||
import 'package:new_learn/core/services/local_notification_service.dart';
|
||||
import 'package:new_learn/core/utils/screen_adapt_template.dart'; // 复用 AppAdapt 适配配置
|
||||
@@ -89,8 +90,12 @@ class TabApp extends StatelessWidget {
|
||||
// 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页,
|
||||
// 而是直接由全屏拦截页先接管。
|
||||
builder: (context, child) {
|
||||
final content =
|
||||
PrivacyStartupGate(child: child ?? const SizedBox.shrink());
|
||||
// ReportRedirectNotifier 必须包在 MaterialApp 里面(能拿到 GoRouterState)
|
||||
// 但在 PrivacyStartupGate 外层(隐私同意后的页面才能拦截)
|
||||
final withRedirect = ReportRedirectNotifier(
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
final content = PrivacyStartupGate(child: withRedirect);
|
||||
final bootstrapped = _LocalNotificationBootstrapper(child: content);
|
||||
if (kReleaseMode) return bootstrapped;
|
||||
return TalkerWrapper(
|
||||
|
||||
@@ -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({
|
||||
@@ -84,14 +85,18 @@ class AudioPlayerCard extends StatefulWidget {
|
||||
final List<double> playbackSpeeds;
|
||||
|
||||
@override
|
||||
State<AudioPlayerCard> createState() => _AudioPlayerCardState();
|
||||
AudioPlayerCardState createState() => AudioPlayerCardState();
|
||||
}
|
||||
|
||||
class _AudioPlayerCardState extends State<AudioPlayerCard>
|
||||
with WidgetsBindingObserver {
|
||||
class AudioPlayerCardState extends State<AudioPlayerCard>
|
||||
with WidgetsBindingObserver
|
||||
implements MediaPausable {
|
||||
/// 底层播放器(just_audio)
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
|
||||
/// 所属媒体暂停作用域(随挂载位置自动注册/注销)
|
||||
MediaPauseRegistry? _pauseScope;
|
||||
|
||||
/// 是否正在加载(setUrl 未完成)
|
||||
bool _loading = true;
|
||||
|
||||
@@ -115,14 +120,33 @@ 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();
|
||||
}
|
||||
|
||||
/// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
|
||||
@override
|
||||
void pause() {
|
||||
_player.pause();
|
||||
}
|
||||
|
||||
/// App 切后台 / 来电 → 自动暂停
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
@@ -398,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 ? '本地音频' : '音频'),
|
||||
@@ -422,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,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【CenterToast】居中轻量提示 —— 对标 uni.showToast(icon: 'none')
|
||||
///
|
||||
/// 一、适用场景:
|
||||
/// 表单校验失败("请填写账号"/"请填写密码")、轻量成功提示等
|
||||
/// 不需要用户确认、1.2s 自动消失、不阻塞交互(IgnorePointer)。
|
||||
///
|
||||
/// 二、与 SnackBar / ConfirmBox 的分工:
|
||||
/// CenterToast → 短暂轻量提示,自动消失,中间弹出
|
||||
/// SnackBar → 底部提示(信息可能较长),可手动关闭
|
||||
/// ConfirmBox → 需要用户决策的确认框,双按钮
|
||||
///
|
||||
/// 三、用法:
|
||||
/// CenterToast.show(context, '请填写账号');
|
||||
/// ============================================================
|
||||
class CenterToast {
|
||||
CenterToast._();
|
||||
|
||||
static OverlayEntry? _current;
|
||||
|
||||
static void show(
|
||||
BuildContext context,
|
||||
String msg, {
|
||||
Duration duration = const Duration(milliseconds: 1200),
|
||||
}) {
|
||||
// 连续调用时先移除上一个,避免叠加
|
||||
_current?.remove();
|
||||
|
||||
late final OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.75),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Text(
|
||||
msg,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
_current = entry;
|
||||
Overlay.of(context).insert(entry);
|
||||
Future<void>.delayed(duration, () {
|
||||
if (entry.mounted && _current == entry) {
|
||||
entry.remove();
|
||||
_current = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【SubjectTag】学科标签(对标 Vue components/SubjectTag/SubjectTag.vue)
|
||||
///
|
||||
/// 四学科配色(与 Vue subjectMap 一致),未知学科兜底数学蓝:
|
||||
/// 数学 #3D7DF5 / #E5F0FF 物理 #A45CFF / #F5ECFF
|
||||
/// 英语 #E5A301 / #FFF6E3 化学 #2DB985 / #E5FAF3
|
||||
///
|
||||
/// 描边:Vue 用 ::before + mask-composite 画 2rpx 的学科色渐变描边
|
||||
/// (两端 60% 学科色、中间 60% 近白色);Flutter 用"外层渐变容器 +
|
||||
/// 内层底色容器留 1px 边"的嵌套方案等价实现。
|
||||
///
|
||||
/// 原 class 模块私有组件,因错题本/我的收藏列表也需要,按项目规范
|
||||
/// 提升为 core/components 全局公共组件。
|
||||
/// ============================================================
|
||||
class SubjectTag extends StatelessWidget {
|
||||
const SubjectTag({super.key, required this.subject});
|
||||
|
||||
final String subject;
|
||||
|
||||
static const Map<String, _SubjectColor> _map = {
|
||||
// 数学:描边 rgba(90,158,255,.6) → rgba(242,248,255,.6)
|
||||
'数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF, 0x995A9EFF, 0x99F2F8FF),
|
||||
// 物理:描边 rgba(182,127,255,.6) → rgba(252,246,255,.6)
|
||||
'物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF, 0x99B67FFF, 0x99FCF6FF),
|
||||
// 英语:描边 rgba(247,194,82,.6) → rgba(255,251,243,.6)
|
||||
'英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3, 0x99F7C252, 0x99FFFBF3),
|
||||
// 化学:描边 rgba(91,209,165,.6) → rgba(240,255,248,.6)
|
||||
'化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3, 0x995BD1A5, 0x99F0FFF8),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = subject.trim();
|
||||
if (t.isEmpty) return const SizedBox.shrink();
|
||||
final c =
|
||||
_map[t] ??
|
||||
const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF, 0x995A9EFF, 0x99F2F8FF);
|
||||
|
||||
// Vue border-radius: 12rpx
|
||||
final outerRadius = BorderRadius.circular(6.r);
|
||||
// Vue ::before padding: 2rpx(描边厚度)
|
||||
final borderWidth = 1.w;
|
||||
|
||||
return Container(
|
||||
// 外层:学科色横向渐变,露出的 1px 边即"渐变描边"
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(c.borderA), Color(c.borderB), Color(c.borderA)],
|
||||
),
|
||||
borderRadius: outerRadius,
|
||||
),
|
||||
child: Container(
|
||||
margin: EdgeInsets.all(borderWidth),
|
||||
// Vue padding: 4rpx 14rpx,扣除 2rpx 描边后内层 2rpx 12rpx
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c.bg),
|
||||
borderRadius: BorderRadius.circular(6.r - borderWidth),
|
||||
),
|
||||
// Vue white-space: nowrap:学科名不换行
|
||||
child: Text(
|
||||
t,
|
||||
softWrap: false,
|
||||
style: TextStyle(fontSize: 11, color: Color(c.fg)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubjectColor {
|
||||
const _SubjectColor(this.fg, this.bg, this.borderA, this.borderB);
|
||||
|
||||
final int fg; // 文字色
|
||||
final int bg; // 底色
|
||||
final int borderA; // 渐变描边两端色(60% 学科色)
|
||||
final int borderB; // 渐变描边中间色(60% 近白)
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
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';
|
||||
import 'package:new_learn/core/components/image_preview_viewer.dart';
|
||||
import 'package:new_learn/core/network/oss_api.dart';
|
||||
import 'package:new_learn/core/network/oss_uploader.dart';
|
||||
import 'package:new_learn/core/services/image_processor.dart';
|
||||
import 'package:new_learn/core/services/picker_service.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【Upload 组件】多图上传组件(对标 Vue components/Upload/Upload.vue�?
|
||||
///
|
||||
/// 一、功能:
|
||||
/// 1. edit 模式:相�?拍照多�?�?压缩 + EXIF 修正 �?OSS 直传 �?
|
||||
/// 调用业务接口关联笔记 �?本地图片列表刷新
|
||||
/// 2. view 模式:仅展示图片网格,无添加/删除按钮
|
||||
/// 3. 点击图片 �?全屏预览(photo_view 双指缩放 + 左右切换�?
|
||||
/// 4. 底部可配�?拍摄规范"提示文本(title == '上传图片作答' 时显示)
|
||||
///
|
||||
/// 二、Props 对照�?
|
||||
/// - [maxCount] : 最多上传张数(默认 10�?
|
||||
/// - [mode] : UploadMode.edit / UploadMode.view(默�?edit�?
|
||||
/// - [title] : 标题文案(默�?'上传笔记'�?
|
||||
/// - [initialImageList]: 初始已上传图片列�?
|
||||
/// - [type] : 类型:handouts / questions / AIGrading / feedback
|
||||
/// - [ids] : 业务 id(讲�?id / 题目 id�?
|
||||
/// - [onChanged] : 图片列表变化回调(相当于 Vue �?change 事件�?
|
||||
///
|
||||
/// 三、上传链路(对齐 Vue + my_page.dart 头像上传流程):
|
||||
/// �?PickerService.showPickSheet 选图(含权限前置弹窗�?
|
||||
/// �?ImageProcessor.process 压缩 + EXIF 方向修正 + 尺寸过滤
|
||||
/// �?OssUploader.uploadImage OSS 直传(scene �?type 映射�?
|
||||
/// �?NoteApi 笔记关联(handouts/question 走业务接口,
|
||||
/// AIGrading/feedback 仅本地维护列表)
|
||||
///
|
||||
/// 四、type �?OSS scene / 笔记接口映射�?
|
||||
/// type | UploadScene | 笔记接口
|
||||
/// ─────────────┼────────────────┼────────────
|
||||
/// handouts | note | HandoutNoteAdd/Del
|
||||
/// questions | note | QuestionNoteAdd/Del
|
||||
/// AIGrading | aiGrading | 无(仅本地列表)
|
||||
/// feedback | note | 无(仅本地列表)
|
||||
///
|
||||
/// 五、尺寸过滤策略(对齐 Vue filterOversizeImages):
|
||||
/// - handouts / questions 类型:跳过尺寸校验(讲义/题目图可能很大)
|
||||
/// - 其它类型:宽高必须在 14~4000 像素之间
|
||||
/// ============================================================
|
||||
|
||||
/// 上传组件模式
|
||||
enum UploadMode { edit, view }
|
||||
|
||||
/// 上传组件类型
|
||||
enum UploadType { handouts, questions, aiGrading, feedback }
|
||||
|
||||
/// 选图后的“原始图 + 压缩处理图”配对
|
||||
typedef UploadPair = ({PickedImage origin, ProcessedImage processed});
|
||||
|
||||
/// 图片项(统一承载:本地已�?+ 已上传)
|
||||
class UploadItem {
|
||||
const UploadItem({
|
||||
this.id,
|
||||
required this.url,
|
||||
this.thumbnailUrl,
|
||||
this.localBytes,
|
||||
this.localEntity,
|
||||
this.isUploading = false,
|
||||
});
|
||||
|
||||
/// 服务器返回的笔记 id(删除用;本地图片无 id�?
|
||||
final dynamic id;
|
||||
|
||||
/// 图片 URL(已上传)或本地路径(未上传�?
|
||||
final String url;
|
||||
|
||||
/// 缩略�?URL
|
||||
final String? thumbnailUrl;
|
||||
|
||||
/// 本地字节(Web 预览用,上传完成后置空)
|
||||
final List<int>? localBytes;
|
||||
|
||||
/// 原生相册实体(原生端缩略图预览用,上传完成后置空�?
|
||||
final dynamic localEntity;
|
||||
|
||||
/// 是否正在上传中(用于显示加载态)
|
||||
final bool isUploading;
|
||||
|
||||
/// 是否为网络图(已上传�?OSS�?
|
||||
bool get isNetwork => url.startsWith('http');
|
||||
|
||||
factory UploadItem.fromNoteImage(NoteImage note) => UploadItem(
|
||||
id: note.id,
|
||||
url: note.url,
|
||||
thumbnailUrl: note.thumbnailUrl,
|
||||
);
|
||||
}
|
||||
|
||||
class Upload extends StatefulWidget {
|
||||
const Upload({
|
||||
super.key,
|
||||
this.maxCount = 10,
|
||||
this.mode = UploadMode.edit,
|
||||
this.title = '上传笔记',
|
||||
this.initialImageList = const [],
|
||||
this.type = UploadType.handouts,
|
||||
this.ids,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
/// 最多上传张�?
|
||||
final int maxCount;
|
||||
|
||||
/// 模式:编�?/ 查看
|
||||
final UploadMode mode;
|
||||
|
||||
/// 标题文案
|
||||
final String title;
|
||||
|
||||
/// 初始图片列表(已上传的网络图�?
|
||||
final List<UploadItem> initialImageList;
|
||||
|
||||
/// 上传类型
|
||||
final UploadType type;
|
||||
|
||||
/// 业务 id(讲�?id / 题目 id�?
|
||||
final dynamic ids;
|
||||
|
||||
/// 列表变化回调
|
||||
final ValueChanged<List<UploadItem>>? onChanged;
|
||||
|
||||
@override
|
||||
State<Upload> createState() => UploadState();
|
||||
}
|
||||
|
||||
class UploadState extends State<Upload> {
|
||||
final List<UploadItem> _imageList = [];
|
||||
int _uploadingCount = 0;
|
||||
bool _choosing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_imageList.addAll(widget.initialImageList);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant Upload oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 外部 initialImageList 变化时同步(类似 Vue �?watch defaultImageList�?
|
||||
if (oldWidget.initialImageList != widget.initialImageList) {
|
||||
_imageList
|
||||
..clear()
|
||||
..addAll(widget.initialImageList);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 对外暴露方法 =====================
|
||||
|
||||
/// 触发选图(等�?Vue �?chooseImage�?
|
||||
Future<void> chooseImage() async => _chooseImage();
|
||||
|
||||
/// 获取当前图片列表
|
||||
List<UploadItem> getImageList() => List.unmodifiable(_imageList);
|
||||
|
||||
/// 正在上传的数�?
|
||||
int get uploadingCount => _uploadingCount;
|
||||
|
||||
/// 是否正在选图
|
||||
bool get choosing => _choosing;
|
||||
|
||||
// ===================== 工具方法 =====================
|
||||
|
||||
void _notifyChanged() {
|
||||
widget.onChanged?.call(List.unmodifiable(_imageList));
|
||||
}
|
||||
|
||||
/// 映射 type �?OSS scene
|
||||
UploadScene get _uploadScene {
|
||||
switch (widget.type) {
|
||||
case UploadType.aiGrading:
|
||||
return UploadScene.aiGrading;
|
||||
case UploadType.handouts:
|
||||
case UploadType.questions:
|
||||
case UploadType.feedback:
|
||||
return UploadScene.note;
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否跳过尺寸校验(讲�?题目大图允许�?
|
||||
bool get _skipSizeLimit =>
|
||||
widget.type == UploadType.handouts ||
|
||||
widget.type == UploadType.questions;
|
||||
|
||||
void _toast(String message) {
|
||||
CenterToast.show(context, message);
|
||||
}
|
||||
|
||||
void _showLoading(String message) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => PopScope(
|
||||
canPop: false,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 28.w, vertical: 20.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(color: Colors.white, fontSize: 13.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _hideLoading() {
|
||||
if (Navigator.of(context, rootNavigator: true).canPop()) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
}
|
||||
|
||||
String _replaceExtension(String fileName, String extension) {
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final safeExtension = extension.toLowerCase();
|
||||
if (dotIndex <= 0) return '$fileName.$safeExtension';
|
||||
return '${fileName.substring(0, dotIndex)}.$safeExtension';
|
||||
}
|
||||
|
||||
// ===================== 选图 =====================
|
||||
|
||||
Future<void> _chooseImage() async {
|
||||
if (_choosing || _uploadingCount > 0) {
|
||||
_toast('图片上传中,请稍后再选择');
|
||||
return;
|
||||
}
|
||||
final remainCount = widget.maxCount - _imageList.length - _uploadingCount;
|
||||
if (remainCount <= 0) {
|
||||
_toast('最多上传${widget.maxCount}张图片');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _choosing = true);
|
||||
bool processingLoadingShown = false;
|
||||
try {
|
||||
final picked = await PickerService.instance.showPickSheet(
|
||||
context,
|
||||
maxCount: remainCount,
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
|
||||
// 显示"上传中..."遮罩(压�?+ 尺寸过滤可能耗时几秒�?
|
||||
_showLoading('上传中...');
|
||||
processingLoadingShown = true;
|
||||
|
||||
// 压缩 + EXIF 修正 + 尺寸过滤
|
||||
final List<UploadPair> validList = [];
|
||||
|
||||
for (final img in picked) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (_skipSizeLimit) {
|
||||
validList.add((origin: img, processed: processed));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 尺寸校验:由 ImageProcessor 内部 min/max 拦截�?
|
||||
// 返回 null 说明尺寸不符合。这里按大小区分提示�?
|
||||
// (ImageProcessor 只返�?null,不区分过小/过大�?
|
||||
// 这里简化为"不符合要�?,与 Vue 的核心提示一致。)
|
||||
validList.add((origin: img, processed: processed));
|
||||
}
|
||||
|
||||
if (validList.isEmpty) {
|
||||
_toast('图片尺寸不符合要求,请重新选择');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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');
|
||||
} finally {
|
||||
if (processingLoadingShown) _hideLoading();
|
||||
if (mounted) setState(() => _choosing = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 上传 =====================
|
||||
|
||||
Future<void> _startUpload(
|
||||
List<({PickedImage origin, ProcessedImage processed})> items,
|
||||
) async {
|
||||
if (items.isEmpty) return;
|
||||
|
||||
final canUploadCount =
|
||||
widget.maxCount - _imageList.length - _uploadingCount;
|
||||
final safeEnd = canUploadCount.clamp(0, items.length);
|
||||
final safeItems =
|
||||
safeEnd > 0 ? items.sublist(0, safeEnd) : <({
|
||||
PickedImage origin,
|
||||
ProcessedImage processed
|
||||
})>[];
|
||||
if (safeItems.isEmpty) {
|
||||
_toast('最多上传${widget.maxCount}张图片');
|
||||
return;
|
||||
}
|
||||
if (safeItems.length < items.length) {
|
||||
_toast('最多还能上传${safeItems.length}张图片');
|
||||
}
|
||||
|
||||
final pendingCount = safeItems.length;
|
||||
_uploadingCount += pendingCount;
|
||||
|
||||
try {
|
||||
for (final item in safeItems) {
|
||||
try {
|
||||
final fileName = _replaceExtension(
|
||||
item.origin.name ??
|
||||
'img_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||
item.processed.extension,
|
||||
);
|
||||
|
||||
// OSS 直传
|
||||
final url = await OssUploader.uploadImage(
|
||||
bytes: item.processed.bytes,
|
||||
fileName: fileName,
|
||||
contentType: item.processed.contentType,
|
||||
scene: _uploadScene,
|
||||
);
|
||||
|
||||
// 业务接口关联笔记
|
||||
await _noteAdd(url);
|
||||
} finally {
|
||||
_uploadingCount = (_uploadingCount - 1).clamp(0, 999);
|
||||
}
|
||||
}
|
||||
_toast('上传成功');
|
||||
} catch (e) {
|
||||
_toast('笔记上传失败,请检查网络后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 调业务接口添加笔记(�?type 分流�?
|
||||
Future<void> _noteAdd(String url) async {
|
||||
switch (widget.type) {
|
||||
case UploadType.handouts:
|
||||
if (widget.ids != null) {
|
||||
final note = await NoteApi.instance.handoutNoteAdd(
|
||||
id: widget.ids,
|
||||
url: url,
|
||||
thumbnailUrl: url,
|
||||
);
|
||||
if (note.url.isNotEmpty && note.id != null) {
|
||||
setState(() {
|
||||
_imageList.add(UploadItem.fromNoteImage(note));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case UploadType.questions:
|
||||
if (widget.ids != null) {
|
||||
final note = await NoteApi.instance.questionNoteAdd(
|
||||
id: widget.ids,
|
||||
url: url,
|
||||
thumbnailUrl: url,
|
||||
);
|
||||
if (note.url.isNotEmpty && note.id != null) {
|
||||
setState(() {
|
||||
_imageList.add(UploadItem.fromNoteImage(note));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case UploadType.aiGrading:
|
||||
case UploadType.feedback:
|
||||
setState(() {
|
||||
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
|
||||
});
|
||||
break;
|
||||
}
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
// ===================== 删除 =====================
|
||||
|
||||
Future<void> _removeImg(UploadItem item) async {
|
||||
try {
|
||||
switch (widget.type) {
|
||||
case UploadType.handouts:
|
||||
if (widget.ids != null && item.id != null) {
|
||||
final ok = await NoteApi.instance.handoutNoteDel(item.id);
|
||||
if (ok) {
|
||||
setState(() {
|
||||
_imageList.removeWhere((e) => e.id == item.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case UploadType.questions:
|
||||
if (widget.ids != null && item.id != null) {
|
||||
final ok = await NoteApi.instance.questionNoteDel(item.id);
|
||||
if (ok) {
|
||||
setState(() {
|
||||
_imageList.removeWhere((e) => e.id == item.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case UploadType.aiGrading:
|
||||
case UploadType.feedback:
|
||||
setState(() {
|
||||
_imageList.removeWhere((e) => e.url == item.url);
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
_toast('删除失败,请检查网络后重试');
|
||||
}
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
// ===================== 预览 =====================
|
||||
|
||||
void _previewImage(int index) {
|
||||
final items = _imageList
|
||||
.map(
|
||||
(item) => PreviewImageItem(
|
||||
imageProvider: _imageProviderOf(item),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
ImagePreviewViewer.show(context, items: items, initialIndex: index);
|
||||
}
|
||||
|
||||
ImageProvider _imageProviderOf(UploadItem item) {
|
||||
if (item.isNetwork) {
|
||||
return CachedNetworkImageProvider(ImgUrl.build(item.url));
|
||||
}
|
||||
if (item.localBytes != null) {
|
||||
return MemoryImage(Uint8List.fromList(item.localBytes!));
|
||||
}
|
||||
return const AssetImage(''); // 兜底
|
||||
}
|
||||
|
||||
// ===================== Build =====================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 模式:view 且没有图片时不显示(对齐 Vue v-if)
|
||||
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
|
||||
return _buildUploadBox();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildUploadBox() {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(14.w),
|
||||
margin: EdgeInsets.only(bottom: 10.h),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFBFD),
|
||||
border: Border.all(color: const Color(0xFFE3ECFF), width: 1.r),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
_buildGrid(),
|
||||
if (widget.mode == UploadMode.edit &&
|
||||
widget.title == '上传图片作答')
|
||||
_buildTip(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
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(
|
||||
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} 张',
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrid() {
|
||||
final itemSize = 67.w;
|
||||
final showAdd = widget.mode == UploadMode.edit &&
|
||||
(_imageList.length + _uploadingCount) < widget.maxCount;
|
||||
final totalCount = _imageList.length + (showAdd ? 1 : 0);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: Wrap(
|
||||
spacing: 10.w,
|
||||
runSpacing: 10.h,
|
||||
children: [
|
||||
for (int i = 0; i < _imageList.length; i++)
|
||||
_buildImageItem(_imageList[i], i, itemSize),
|
||||
if (showAdd) _buildAddButton(itemSize),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddButton(double size) {
|
||||
return GestureDetector(
|
||||
onTap: _chooseImage,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Center(
|
||||
child: Image.network(
|
||||
ImgUrl.build('/StaticResource/2026-04-01/add.png'),
|
||||
width: 19.5.w,
|
||||
height: 19.5.w,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.add,
|
||||
size: 19.5.w,
|
||||
color: const Color(0xFFB0B8C4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageItem(UploadItem item, int index, double size) {
|
||||
return GestureDetector(
|
||||
onTap: () => _previewImage(index),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (item.isNetwork)
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(item.url),
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const ColoredBox(
|
||||
color: Color(0xFFEEF1F5),
|
||||
),
|
||||
)
|
||||
else if (item.localBytes != null)
|
||||
Image.memory(
|
||||
Uint8List.fromList(item.localBytes!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
else
|
||||
const ColoredBox(color: Color(0xFFEEF1F5)),
|
||||
// 删除按钮
|
||||
if (widget.mode == UploadMode.edit)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => _removeImg(item),
|
||||
child: Image.network(
|
||||
ImgUrl.build('/StaticResource/2026-04-14/remove.png'),
|
||||
width: 18.w,
|
||||
height: 18.w,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 18.w,
|
||||
height: 18.w,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.close,
|
||||
size: 12, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTip() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 15.h),
|
||||
child: Text(
|
||||
'图片作答拍摄规范:\n'
|
||||
'1. 采光:光线充足,无阴影、无反光遮挡答题区\n'
|
||||
'2. 角度:纸张铺平,垂直俯拍,画面不歪斜\n'
|
||||
'3. 画质:完整拍下整张纸,文字清晰无裁切模糊\n'
|
||||
'4. 卷面:答题区仅留正式作答,草稿另用草稿纸\n'
|
||||
'5. 修改:错处双线划去,旁侧重写,禁止反复涂黑叠写\n'
|
||||
'提示:画面越规整清晰,识别批改越准确',
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 全屏旋转预览(对标 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);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -107,11 +108,12 @@ class VideoPlayerView extends StatefulWidget {
|
||||
final ChewieProgressColors? materialProgressColors;
|
||||
|
||||
@override
|
||||
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
||||
VideoPlayerViewState createState() => VideoPlayerViewState();
|
||||
}
|
||||
|
||||
class _VideoPlayerViewState extends State<VideoPlayerView>
|
||||
with WidgetsBindingObserver {
|
||||
class VideoPlayerViewState extends State<VideoPlayerView>
|
||||
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,6 +162,12 @@ class _VideoPlayerViewState extends State<VideoPlayerView>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
|
||||
@override
|
||||
void pause() {
|
||||
_videoController?.pause();
|
||||
}
|
||||
|
||||
/// App 切后台 / 来电 → 自动暂停,防止声音继续播
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
|
||||
@@ -30,6 +30,7 @@ class CurrentUserExtension {
|
||||
this.userAvatarReason = '',
|
||||
this.isShowGuide = false,
|
||||
this.isShowReport = false,
|
||||
this.isShowAfterReport = false,
|
||||
this.id = 0,
|
||||
});
|
||||
|
||||
@@ -48,6 +49,9 @@ class CurrentUserExtension {
|
||||
final String userAvatarReason;
|
||||
final bool isShowGuide;
|
||||
final bool isShowReport;
|
||||
|
||||
/// 是否显示出门测试报告(默认 false,后端置 true 后,用户完成报告查看后置回 false)
|
||||
final bool isShowAfterReport;
|
||||
final int id;
|
||||
|
||||
/// JSON → 对象(服务器响应)
|
||||
@@ -74,6 +78,7 @@ class CurrentUserExtension {
|
||||
userAvatarReason: payload['userAvatarReason'] as String? ?? '',
|
||||
isShowGuide: payload['isShowGuide'] as bool? ?? false,
|
||||
isShowReport: payload['isShowReport'] as bool? ?? false,
|
||||
isShowAfterReport: payload['isShowAfterReport'] as bool? ?? false,
|
||||
id: _toInt(payload['id']),
|
||||
);
|
||||
}
|
||||
@@ -96,12 +101,17 @@ class CurrentUserExtension {
|
||||
'userAvatarReason': userAvatarReason,
|
||||
'isShowGuide': isShowGuide,
|
||||
'isShowReport': isShowReport,
|
||||
'isShowAfterReport': isShowAfterReport,
|
||||
'id': id,
|
||||
};
|
||||
}
|
||||
|
||||
/// 局部更新(目前只用得上 avatar:头像上传成功后替换 URL)
|
||||
CurrentUserExtension copyWith({String? avatar}) {
|
||||
/// 局部更新(目前只用得上 avatar / isShowReport / isShowAfterReport)
|
||||
CurrentUserExtension copyWith({
|
||||
String? avatar,
|
||||
bool? isShowReport,
|
||||
bool? isShowAfterReport,
|
||||
}) {
|
||||
return CurrentUserExtension(
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
@@ -117,7 +127,8 @@ class CurrentUserExtension {
|
||||
userAvatarStatus: userAvatarStatus,
|
||||
userAvatarReason: userAvatarReason,
|
||||
isShowGuide: isShowGuide,
|
||||
isShowReport: isShowReport,
|
||||
isShowReport: isShowReport ?? this.isShowReport,
|
||||
isShowAfterReport: isShowAfterReport ?? this.isShowAfterReport,
|
||||
id: id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -38,7 +40,7 @@ import 'package:talker_dio_logger/talker_dio_logger.dart';
|
||||
/// ============================================================
|
||||
class DioClient {
|
||||
/// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默
|
||||
static const bool enableNetworkLog = false;
|
||||
static const bool enableNetworkLog = true;
|
||||
|
||||
// ---------- 1. 单例:整个 App 只有一个 Dio ----------
|
||||
DioClient._()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:new_learn/core/api/auth/api.dart';
|
||||
import 'package:new_learn/core/models/user_model.dart';
|
||||
import 'package:new_learn/core/storage/user_storage.dart';
|
||||
|
||||
@@ -86,6 +87,49 @@ class CurrentUserNotifier extends AsyncNotifier<CurrentUserExtension?> {
|
||||
// 也还是最新头像。
|
||||
await UserStorage.instance.saveUserInfo(updated);
|
||||
}
|
||||
|
||||
/// 强制从服务器拉最新用户信息(对齐 Vue App.vue 的 fetchCurrentUserExtension(true))
|
||||
///
|
||||
/// 使用场景:
|
||||
/// 1. App 回前台(WidgetsBindingObserver resumed):拿最新 isShowReport / isShowAfterReport;
|
||||
/// 2. 报告页调完 ChangeShowReport / ChangeShowAfterReport 后:刷新全局状态;
|
||||
/// 3. 登录成功后:确保后端最新数据。
|
||||
Future<CurrentUserExtension?> refresh() async {
|
||||
try {
|
||||
final ext = await UserApi.getCurrentUserExtension();
|
||||
state = AsyncData(ext);
|
||||
await UserStorage.instance.saveUserInfo(ext);
|
||||
return ext;
|
||||
} catch (_) {
|
||||
// 网络失败:保持现有状态,不要清空(可能本来就是 AsyncData)
|
||||
return state.value;
|
||||
}
|
||||
}
|
||||
|
||||
/// 只改本地报告标记为 false(studentInfo subjectReports 为空时降级处理)
|
||||
///
|
||||
/// 对应 Vue studentInfo.vue 的:
|
||||
/// userInfo.isShowReport = false; uni.setStorageSync('userInfo', userInfo);
|
||||
/// app.setUserInfo(Object.assign({}, app.globalData.userInfo, { isShowReport: false }));
|
||||
///
|
||||
/// 注意:不走后端接口!因为 subjectReports 为空意味着后端可能也没报告数据,
|
||||
/// 直接本地置 false 避免拦截器下次又打回来。
|
||||
Future<void> markShowReportFalse() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final updated = current.copyWith(isShowReport: false);
|
||||
state = AsyncData(updated);
|
||||
await UserStorage.instance.saveUserInfo(updated);
|
||||
}
|
||||
|
||||
/// 只改本地出门测试报告标记为 false(降级场景用)
|
||||
Future<void> markShowAfterReportFalse() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final updated = current.copyWith(isShowAfterReport: false);
|
||||
state = AsyncData(updated);
|
||||
await UserStorage.instance.saveUserInfo(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录态变化信号(给 go_router 的 refreshListenable 用)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/providers/user_provider.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【报告状态全局拦截 + App 生命周期监听】
|
||||
///
|
||||
/// 对标 Vue App.vue 的 checkAndRedirectToReport() + onShow force 拉 userInfo。
|
||||
///
|
||||
/// 一、拦截时机(两处):
|
||||
/// 1. userProvider 状态变化(set() / refresh())→ _checkAndRedirect()
|
||||
/// :登录成功 / refresh 后都自动检查;
|
||||
/// 2. App 回前台(WidgetsBindingObserver.resumed)→ force refresh() → 自动检查
|
||||
/// :从胶囊面板/后台回来时拉最新 isShowReport;
|
||||
///
|
||||
/// 二、拦截条件(全部满足才跳):
|
||||
/// ✅ 有登录态(DioClient.accessToken != null)
|
||||
/// ✅ isShowReport=true 或 isShowAfterReport=true
|
||||
/// ✅ 当前路由在 tab 页面(动态判断,不硬编码列表)
|
||||
/// —— go_router 的 StatefulShellRoute.indexedStack 内的路由就是 tab 页
|
||||
///
|
||||
/// 三、动态判断 tab 页面的方法(不硬编码):
|
||||
/// 通过 GoRouterState 的 matchedLocation,判断是否能匹配到 ShellBranch 内的路径
|
||||
/// (即 /, /recite, /class, /my 这四个——但这些路径是由路由配置决定的,
|
||||
/// 所以判断逻辑跟着路由配置走,别人改 tab 路径这里自动生效)。
|
||||
///
|
||||
/// ============================================================
|
||||
|
||||
/// 是否为 tab 路由(动态判断:4 个 tab 的路径由路由配置决定)
|
||||
///
|
||||
/// go_router 的 StatefulShellBranch 内配置了 4 条路径,
|
||||
/// 直接从路由表读取所有 ShellBranch 的 path 集合,而不是硬编码。
|
||||
/// 但 ShellBranch 的 routes 里只有 GoRoute,path 是字段。
|
||||
/// 所以这里采用一个更稳的方式:判断当前是否在 StatefulShellRoute 里,
|
||||
/// 或者直接在 app_router.dart 里暴露一个 tabPaths 常量。
|
||||
///
|
||||
/// 目前路由表固定,这里用 tabPaths 常量(从 app_router 导出)。
|
||||
import 'package:new_learn/core/router/app_router.dart';
|
||||
|
||||
/// 拦截器:当 userProvider 变化时检查是否需要跳报告页
|
||||
class ReportRedirectNotifier extends ConsumerStatefulWidget {
|
||||
const ReportRedirectNotifier({super.key, required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<ReportRedirectNotifier> createState() =>
|
||||
_ReportRedirectNotifierState();
|
||||
}
|
||||
|
||||
class _ReportRedirectNotifierState extends ConsumerState<ReportRedirectNotifier>
|
||||
with WidgetsBindingObserver {
|
||||
/// 防止重复跳转标记
|
||||
bool _isRedirecting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
talker.debug('[ReportRedirect] initState 🔥 被创建了');
|
||||
// 监听 userProvider 后续变化
|
||||
ref.listenManual(currentUserProvider, (prev, next) {
|
||||
talker.debug('[ReportRedirect] listenManual 触发!prev=$prev next=$next');
|
||||
_checkAndRedirect();
|
||||
});
|
||||
// 立即检查一次!listenManual 只监听变化不会主动触发,
|
||||
// 登录后 userProvider 已经是 AsyncData 状态了,必须主动调。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
talker.debug(
|
||||
'[ReportRedirect] addPostFrameCallback → 主动调 _checkAndRedirect',
|
||||
);
|
||||
_checkAndRedirect();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 生命周期变化:resumed 时 force 拉最新 userInfo
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_onAppResumed();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAppResumed() async {
|
||||
// 只有已登录才 force 拉
|
||||
if (DioClient.instance.accessToken == null) return;
|
||||
talker.debug('[ReportRedirect] App resumed → force refresh userInfo');
|
||||
await ref.read(currentUserProvider.notifier).refresh();
|
||||
_checkAndRedirect();
|
||||
}
|
||||
|
||||
/// 核心拦截逻辑:判断当前路由 + 报告标记 → 决定跳不跳
|
||||
void _checkAndRedirect() {
|
||||
talker.debug('[ReportRedirect] ===== _checkAndRedirect 开始 ====');
|
||||
if (_isRedirecting) {
|
||||
talker.debug('[ReportRedirect] _isRedirecting=true,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
final user = ref.read(currentUserProvider).value;
|
||||
if (user == null) {
|
||||
talker.debug('[ReportRedirect] user=null(未登录),放行');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: 调试报告用,把后端字段叠加调试开关,上线前删掉下面三行
|
||||
// const _debugExitTest = false;
|
||||
// const _debugPlacementTest = false;
|
||||
const _debugExitTest = false; // true = 强制调试出门测
|
||||
const _debugPlacementTest = false; // true = 强制调试入门测
|
||||
|
||||
talker.debug(
|
||||
'[ReportRedirect] user.isShowReport=${user.isShowReport} '
|
||||
'user.isShowAfterReport=${user.isShowAfterReport} '
|
||||
'debugExit=$_debugExitTest debugPlacement=$_debugPlacementTest',
|
||||
);
|
||||
|
||||
if (!user.isShowReport &&
|
||||
!user.isShowAfterReport &&
|
||||
!_debugExitTest &&
|
||||
!_debugPlacementTest) {
|
||||
talker.debug('[ReportRedirect] ❌ 无报告标记+无调试开关,放行');
|
||||
return;
|
||||
}
|
||||
|
||||
final isExitTest = user.isShowAfterReport || _debugExitTest;
|
||||
final reportType = isExitTest
|
||||
? 'exitTest'
|
||||
: (user.isShowReport || _debugPlacementTest ? 'placementTest' : null);
|
||||
if (reportType == null) {
|
||||
talker.debug('[ReportRedirect] ❌ reportType=null,放行');
|
||||
return;
|
||||
}
|
||||
talker.debug('[ReportRedirect] ✅ 通过标记检查,reportType=$reportType');
|
||||
|
||||
// 当前路由是否在 tab 页面(直接从全局 appRouter 拿,不需要 context)
|
||||
String location = appRouter.state?.matchedLocation ?? '';
|
||||
talker.debug('[ReportRedirect] appRouter.state matchedLocation=$location');
|
||||
final inTab = _isInTabRoute(location);
|
||||
talker.debug(
|
||||
'[ReportRedirect] location=$location kTabPaths=$kTabPaths inTab=$inTab',
|
||||
);
|
||||
if (!inTab) {
|
||||
talker.debug('[ReportRedirect] ❌ 不在 tab 页面,跳过拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
talker.debug('[ReportRedirect] ✅✅✅ 触发跳转 → studentInfo?type=$reportType');
|
||||
_isRedirecting = true;
|
||||
|
||||
try {
|
||||
appRouter.go('/studentInfo', extra: {'type': reportType});
|
||||
talker.debug('[ReportRedirect] appRouter.go 调用成功');
|
||||
} catch (e) {
|
||||
talker.error('[ReportRedirect] appRouter.go 崩了!', e);
|
||||
}
|
||||
|
||||
// 重置标记(go_router 会重建 Widget,这个 State 会被 dispose)
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_isRedirecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 判断当前路径是否在 tab 路由里(动态,不硬编码)
|
||||
///
|
||||
/// go_router 的 StatefulShellRoute 下的 4 个 ShellBranch 里,
|
||||
/// 第一个 GoRoute 的 path 就是 tab 路径。我们从 app_router 导出的
|
||||
/// tabPaths 集合判断,别人改 tab 路径这里自动生效。
|
||||
bool _isInTabRoute(String location) {
|
||||
return kTabPaths.contains(location);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -6,14 +6,37 @@ import 'package:new_learn/features/auth/legal_document_page.dart';
|
||||
import 'package:new_learn/features/auth/legal_documents.dart';
|
||||
import 'package:new_learn/features/auth/login_page.dart';
|
||||
import 'package:new_learn/features/auth/privacy_consent_page.dart';
|
||||
import 'package:new_learn/features/auth/register_page.dart';
|
||||
import 'package:new_learn/features/class/all_student_view.dart';
|
||||
import 'package:new_learn/features/class/class_page.dart';
|
||||
import 'package:new_learn/features/class/my_interact_page.dart';
|
||||
import 'package:new_learn/features/class/my_update_detail_page.dart';
|
||||
import 'package:new_learn/core/api/class/model.dart';
|
||||
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';
|
||||
import 'package:new_learn/features/my/my_report/report_detail_page.dart';
|
||||
import 'package:new_learn/features/my/exam/exam_page.dart';
|
||||
import 'package:new_learn/features/my/settings/settings_page.dart';
|
||||
import 'package:new_learn/features/my/settings/about_app_page.dart';
|
||||
import 'package:new_learn/core/api/lexicon/model.dart';
|
||||
import 'package:new_learn/features/recite/chapters_page.dart';
|
||||
import 'package:new_learn/features/recite/level_over_page.dart';
|
||||
import 'package:new_learn/features/recite/level_start_page.dart';
|
||||
import 'package:new_learn/features/recite/levels_page.dart';
|
||||
import 'package:new_learn/features/recite/recite_args.dart';
|
||||
import 'package:new_learn/features/recite/recite_page.dart';
|
||||
import 'package:new_learn/features/recite/textbook_list_page.dart';
|
||||
import 'package:new_learn/features/report/report_computation_page.dart';
|
||||
import 'package:new_learn/features/report/report_web_view_page.dart';
|
||||
import 'package:new_learn/features/report/student_info_page.dart';
|
||||
import 'package:new_learn/features/task/task_page.dart';
|
||||
import 'package:new_learn/core/router/main_tab_page.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
@@ -64,6 +87,12 @@ import 'package:new_learn/core/providers/user_provider.dart';
|
||||
/// 必须在创建 GoRouter 前全局开启,见 main.dart 的 main()。
|
||||
/// ============================================================
|
||||
|
||||
/// 【kTabPaths】四个底部 tab 路径集合(动态来源,别人改 tab 路由时同步改这里即可)
|
||||
///
|
||||
/// 全局拦截器 ReportRedirectNotifier 用它判断"当前是否在 tab 页面",
|
||||
/// 避免硬编码分散在多个地方。
|
||||
const kTabPaths = <String>{'/', '/recite', '/class', '/my'};
|
||||
|
||||
/// 全局路由表(对标 Vue 的 router/index.ts)
|
||||
final GoRouter appRouter = GoRouter(
|
||||
navigatorKey:
|
||||
@@ -122,6 +151,42 @@ final GoRouter appRouter = GoRouter(
|
||||
MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
|
||||
),
|
||||
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
|
||||
// 我的笔记
|
||||
GoRoute(path: '/myNote', builder: (_, _) => const NotePage()),
|
||||
// 错题本(type=2)/ 我的收藏(type=1)共用 QuestionListPage
|
||||
GoRoute(
|
||||
path: '/myWrong',
|
||||
builder: (_, _) => const QuestionListPage(type: 2),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/myCollect',
|
||||
builder: (_, _) => const QuestionListPage(type: 1),
|
||||
),
|
||||
GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()),
|
||||
GoRoute(path: '/myExam', builder: (_, _) => const ExamPage()),
|
||||
GoRoute(
|
||||
path: '/report-detail',
|
||||
builder: (_, state) {
|
||||
// extra 格式:{'title': '...', 'url': '...'}
|
||||
final extra = state.extra as Map<String, dynamic>? ?? {};
|
||||
return ReportDetailPage(
|
||||
title: extra['title'] as String? ?? '我的报告',
|
||||
url: extra['url'] as String? ?? '',
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
||||
GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()),
|
||||
// ---------- 诊断报告包三个页面(不在 ShellBranch 内,push 时不显示 tabbar) ----------
|
||||
GoRoute(path: '/studentInfo', builder: (_, _) => const StudentInfoPage()),
|
||||
GoRoute(
|
||||
path: '/reportWebView',
|
||||
builder: (_, _) => const ReportWebViewPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/reportComputation',
|
||||
builder: (_, _) => const ReportComputationPage(),
|
||||
),
|
||||
// ---------- 首次启动隐私协议页 ----------
|
||||
GoRoute(
|
||||
path: '/privacy-consent',
|
||||
@@ -141,8 +206,71 @@ final GoRouter appRouter = GoRouter(
|
||||
content: kPrivacyPolicyContent,
|
||||
),
|
||||
),
|
||||
// ---------- 登录页 ----------
|
||||
// ---------- 登录/注册页(未登录可访问) ----------
|
||||
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
|
||||
// ---------- 攻克包 - 截图名单 ----------
|
||||
GoRoute(
|
||||
path: '/conquerPackage/captureList',
|
||||
builder: (_, state) => CaptureListPage(courseId: state.extra as String),
|
||||
),
|
||||
// ---------- 攻克包 - 课前课中 ----------
|
||||
GoRoute(
|
||||
path: '/conquerPackage/beforeDuringClass',
|
||||
builder: (_, state) => BeforeDuringClassPage(
|
||||
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) ----------
|
||||
// 教材列表
|
||||
GoRoute(
|
||||
path: '/recite/textbooks',
|
||||
builder: (_, _) => const TextbookListPage(),
|
||||
),
|
||||
// 词汇章节(bookId 走路径参数,替代 Vue 缓存)
|
||||
GoRoute(
|
||||
path: '/recite/chapters/:bookId',
|
||||
builder: (_, state) => ChaptersPage(
|
||||
bookId: int.parse(state.pathParameters['bookId']!),
|
||||
),
|
||||
),
|
||||
// 关卡列表(Chapter 对象走 extra)
|
||||
GoRoute(
|
||||
path: '/recite/levels',
|
||||
builder: (_, state) => LevelsPage(chapter: state.extra as Chapter),
|
||||
),
|
||||
// 答题页(LevelStartArgs 显式传参,替代 curChapterInfo 缓存)
|
||||
GoRoute(
|
||||
path: '/recite/level-start',
|
||||
builder: (_, state) =>
|
||||
LevelStartPage(args: state.extra as LevelStartArgs),
|
||||
),
|
||||
// 学习小结
|
||||
GoRoute(
|
||||
path: '/recite/level-over',
|
||||
builder: (_, state) => LevelOverPage(args: state.extra as LevelOverArgs),
|
||||
),
|
||||
|
||||
// app_router.dart 的 routes 里加,不用管跳转逻辑
|
||||
// GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()),
|
||||
// ---------- 以后新业务页在这里加,一行一个 ----------
|
||||
@@ -150,7 +278,7 @@ final GoRouter appRouter = GoRouter(
|
||||
);
|
||||
|
||||
/// 登录守卫(对标 vue-router beforeEach):
|
||||
/// 没登录 → 强制去 /login;已登录还访问 /login → 踢回首页
|
||||
/// 没登录 → 强制去 /login;已登录还访问 /login 或 /register → 踢回首页
|
||||
String? _guardLogin(BuildContext context, GoRouterState state) {
|
||||
final acceptedPrivacy = privacyAcceptedNotifier.value;
|
||||
final loggedIn = DioClient.instance.accessToken != null;
|
||||
@@ -159,11 +287,14 @@ String? _guardLogin(BuildContext context, GoRouterState state) {
|
||||
state.matchedLocation == '/user-agreement' ||
|
||||
state.matchedLocation == '/privacy-policy';
|
||||
final atLogin = state.matchedLocation == '/login';
|
||||
final atRegister = state.matchedLocation == '/register';
|
||||
// 未登录可白名单:隐私协议页 / 协议文档 / 登录页 / 注册页
|
||||
final atAuthFree = atLogin || atRegister || atLegalDocument;
|
||||
if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) {
|
||||
return '/privacy-consent';
|
||||
}
|
||||
if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login';
|
||||
if (!loggedIn && !atLogin && !atLegalDocument) return '/login';
|
||||
if (loggedIn && atLogin) return '/';
|
||||
if (!loggedIn && !atAuthFree && !atPrivacyConsent) return '/login';
|
||||
if (loggedIn && (atLogin || atRegister)) return '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,44 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:new_learn/core/models/user_model.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【LoginHistoryItem】登录历史单条记录 —— 对齐 Vue loginHistory
|
||||
///
|
||||
/// account → 登录账号(唯一标识,重复登录时去重用)
|
||||
/// userName → 登录后拿到的昵称(展示用,可能为空)
|
||||
/// password → 记住的密码(明文,存在 secure_storage 里一条 JSON)
|
||||
/// 空串 "" 表示本次没勾选记住密码
|
||||
/// rememberPassword → 是否勾选了记住密码(决定密码是否回填)
|
||||
/// ============================================================
|
||||
class LoginHistoryItem {
|
||||
const LoginHistoryItem({
|
||||
required this.account,
|
||||
required this.userName,
|
||||
required this.password,
|
||||
required this.rememberPassword,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final String userName;
|
||||
final String password;
|
||||
final bool rememberPassword;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'account': account,
|
||||
'userName': userName,
|
||||
'password': password,
|
||||
'rememberPassword': rememberPassword,
|
||||
};
|
||||
|
||||
factory LoginHistoryItem.fromJson(Map<String, dynamic> json) =>
|
||||
LoginHistoryItem(
|
||||
account: json['account'] as String? ?? '',
|
||||
userName: json['userName'] as String? ?? '',
|
||||
password: json['password'] as String? ?? '',
|
||||
rememberPassword: json['rememberPassword'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【UserStorage】用户本地存储(token 安全存储 + 用户信息缓存)
|
||||
///
|
||||
@@ -118,6 +156,36 @@ class UserStorage {
|
||||
Future<void> clearRememberedPassword() =>
|
||||
_secure.delete(key: _rememberedPasswordKey);
|
||||
|
||||
// ---------- 登录历史(多条,最多 3 条,对齐 Vue loginHistory) ----------
|
||||
|
||||
static const String _loginHistoryKey = 'login_history';
|
||||
static const int _maxHistoryCount = 3;
|
||||
|
||||
/// 读取登录历史(没存过返回空列表)
|
||||
Future<List<LoginHistoryItem>> readLoginHistory() async {
|
||||
final raw = await _secure.read(key: _loginHistoryKey);
|
||||
if (raw == null) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.map((e) => LoginHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return []; // 缓存损坏时当没历史处理
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存登录历史(整体替换存,最多保留 3 条)
|
||||
Future<void> saveLoginHistory(List<LoginHistoryItem> items) async {
|
||||
final trimmed = items.length > _maxHistoryCount
|
||||
? items.sublist(0, _maxHistoryCount)
|
||||
: items;
|
||||
await _secure.write(
|
||||
key: _loginHistoryKey,
|
||||
value: jsonEncode(trimmed.map((e) => e.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 一键退出登录:清 Token + 用户信息
|
||||
/// (记住的账号密码保留,方便用户下次登录)
|
||||
Future<void> clearAll() async {
|
||||
|
||||
@@ -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*/?>| |\s)*</\1>',
|
||||
caseSensitive: false,
|
||||
),
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
|
||||
return StemMedia(content: content, audios: audios, videos: videos, blocks: blocks);
|
||||
}
|
||||
@@ -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 ?? '';
|
||||
@@ -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*( )?';
|
||||
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(' ', ' ')
|
||||
.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> ${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('"', '"');
|
||||
}
|
||||
|
||||
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(' ', '')
|
||||
.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(' ', ' ')
|
||||
.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(' ', ' ')
|
||||
.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*(?: )*</label>|<p>.*?(\d+)\.\s*(?: )*</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 []),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【学科图标常量】
|
||||
///
|
||||
/// 对标 Vue utils/constant.js 的 SUBJECT_ICON_MAP。
|
||||
/// 我的报告(my_report)、我的考试(exam)等模块共用,
|
||||
/// 通过学科名(如「数学」「英语」)动态取对应图标。
|
||||
/// ============================================================
|
||||
|
||||
/// 学科名 → 图标资源路径
|
||||
const Map<String, String> kSubjectIconMap = {
|
||||
'语文': '/StaticResource/2026-06-11/shuxue1781163770612.png',
|
||||
'化学': '/StaticResource/2026-06-11/huaxue1781163828443.png',
|
||||
'英语': '/StaticResource/2026-06-11/yingyu1781163725441.png',
|
||||
'物理': '/StaticResource/2026-06-11/wuli1781148438346.png',
|
||||
'数学': '/StaticResource/2026-06-11/shuxue1781163770612.png',
|
||||
'生物': '/StaticResource/2026-06-11/shengwu1781163791768.png',
|
||||
'历史': '/StaticResource/2026-06-11/lishi1781163811822.png',
|
||||
'政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png',
|
||||
'地理': '/StaticResource/2026-06-11/dili1781163844311.png',
|
||||
};
|
||||
|
||||
/// 取学科图标完整 URL(无匹配时用数学兜底,对标 Vue getSubjectIcon)
|
||||
String subjectIconUrl(String subjectName) {
|
||||
final path = kSubjectIconMap[subjectName] ?? kSubjectIconMap['数学']!;
|
||||
return ImgUrl.build(path);
|
||||
}
|
||||
+546
-269
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,429 @@
|
||||
import 'package:flutter/gestures.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/auth/api.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/components/confirm_box.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【RegisterPage】注册页 —— 对照 Vue 版 pages/login/register.vue
|
||||
///
|
||||
/// 一、表单字段(3 个,带校验):
|
||||
/// 账号 6-16 位字母或数字 → /^[a-zA-Z0-9]{6,16}$/
|
||||
/// 密码 6-16 位字母或数字 → /^[a-zA-Z0-9]{6,16}$/
|
||||
/// 确认密码 必须与密码一致
|
||||
///
|
||||
/// 二、注册流程:
|
||||
/// isFormValid → 协议勾选 → 调 UserApi.register({account, password})
|
||||
/// → 成功 Toast "注册成功" → 1 秒后返回登录页
|
||||
///
|
||||
/// 三、与 Vue 的对应关系:
|
||||
/// validateField / validateAll → Flutter 版字段级校验(_validateXxx)
|
||||
/// isFormValid computed → Flutter 版 _isFormValid getter
|
||||
/// debounce 防重复提交 → Flutter 用 _loading 标记防连点
|
||||
/// 密码 MD5 → UserApi.register 内部已处理
|
||||
///
|
||||
/// 四、UI 风格:
|
||||
/// 输入框对齐登录页(灰底 #F9F9F9 + 圆角 24.r),顶部 AppBar 标题"注册账号"。
|
||||
/// ============================================================
|
||||
|
||||
/// 账号/密码校验正则:6-16 位字母或数字(对齐 Vue register.vue 的 validateField)
|
||||
final _kAccountRegex = RegExp(r'^[a-zA-Z0-9]{6,16}$');
|
||||
|
||||
class RegisterPage extends ConsumerStatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
// ---------- 表单控制器 ----------
|
||||
final TextEditingController _accountController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
bool _hasAgreed = false;
|
||||
bool _loading = false;
|
||||
|
||||
// ---------- 错误信息(字段级,与 Vue errors 对齐) ----------
|
||||
String _accountError = '';
|
||||
String _passwordError = '';
|
||||
String _confirmPasswordError = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ==================== 校验(对齐 Vue validateField) ====================
|
||||
|
||||
String _validateAccount() {
|
||||
final v = _accountController.text.trim();
|
||||
if (v.isEmpty) return '请填写账号';
|
||||
if (!_kAccountRegex.hasMatch(v)) return '6-16 位字母或数字';
|
||||
return '';
|
||||
}
|
||||
|
||||
String _validatePassword() {
|
||||
final v = _passwordController.text.trim();
|
||||
if (v.isEmpty) return '请填写密码';
|
||||
if (!_kAccountRegex.hasMatch(v)) return '6-16 位字母或数字';
|
||||
// 密码变化时重校验确认密码
|
||||
if (_confirmPasswordController.text.isNotEmpty) {
|
||||
_confirmPasswordError = _confirmPasswordController.text == v
|
||||
? ''
|
||||
: '两次输入的密码不一致';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _validateConfirmPassword() {
|
||||
final v = _confirmPasswordController.text;
|
||||
if (v.isEmpty) return '请再次输入密码';
|
||||
if (v != _passwordController.text) return '两次输入的密码不一致';
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 表单是否完整有效(对齐 Vue isFormValid computed)
|
||||
bool get _isFormValid {
|
||||
return _kAccountRegex.hasMatch(_accountController.text.trim()) &&
|
||||
_kAccountRegex.hasMatch(_passwordController.text.trim()) &&
|
||||
_confirmPasswordController.text.isNotEmpty &&
|
||||
_confirmPasswordController.text == _passwordController.text;
|
||||
}
|
||||
|
||||
// ==================== 注册逻辑 ====================
|
||||
|
||||
Future<void> _handleRegister() async {
|
||||
if (_loading) return;
|
||||
|
||||
// 全量校验
|
||||
_accountError = _validateAccount();
|
||||
_passwordError = _validatePassword();
|
||||
_confirmPasswordError = _validateConfirmPassword();
|
||||
setState(() {});
|
||||
|
||||
if (!_isFormValid) return;
|
||||
|
||||
// 未勾选协议 → 弹确认框(对齐 Vue register.vue 的协议弹窗)
|
||||
if (!_hasAgreed) {
|
||||
final agreed = await showConfirmBox(
|
||||
context,
|
||||
title: '用户协议及隐私条款',
|
||||
content:
|
||||
'感谢您选择AI爱学蝶变!我们非常重视您的个人信息和隐私保护。'
|
||||
'为了更好地保障您的个人权益,在您使用我们的产品前请务必审慎阅读'
|
||||
'《隐私政策》和《用户协议》内的所有条款。同意并继续开始使用我们的产品和服务!',
|
||||
cancelText: '不同意',
|
||||
confirmText: '同意并继续',
|
||||
);
|
||||
if (!agreed || !mounted) return;
|
||||
setState(() => _hasAgreed = true);
|
||||
}
|
||||
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await UserApi.register(
|
||||
account: _accountController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_toast('注册成功');
|
||||
// 1 秒后回到登录页(context.go 替换当前栈,最稳不依赖 canPop)
|
||||
Future<void>.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) context.go('/login');
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) _toast(e.message);
|
||||
} catch (e) {
|
||||
if (mounted) _toast('注册失败,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toast(String msg) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 导航 ====================
|
||||
|
||||
void _goToAgreement() => context.push('/user-agreement');
|
||||
void _goToPrivacy() => context.push('/privacy-policy');
|
||||
|
||||
// ==================== UI ====================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text('注册账号', style: TextStyle(color: Color(0xFF323233))),
|
||||
iconTheme: const IconThemeData(color: Color(0xFF323233)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () =>
|
||||
context.canPop() ? context.pop() : context.go('/login'),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 25.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// 账号
|
||||
_buildField(
|
||||
label: '账号',
|
||||
controller: _accountController,
|
||||
hintText: '请输入您的账号',
|
||||
errorText: _accountError,
|
||||
onChanged: (_) =>
|
||||
setState(() => _accountError = _validateAccount()),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
// 密码
|
||||
_buildField(
|
||||
label: '密码',
|
||||
controller: _passwordController,
|
||||
hintText: '请输入您的密码',
|
||||
obscureText: _obscurePassword,
|
||||
errorText: _passwordError,
|
||||
onChanged: (_) => setState(() {
|
||||
_passwordError = _validatePassword();
|
||||
}),
|
||||
onToggleObscure: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
// 确认密码
|
||||
_buildField(
|
||||
label: '确认密码',
|
||||
controller: _confirmPasswordController,
|
||||
hintText: '请再次输入密码',
|
||||
obscureText: _obscureConfirmPassword,
|
||||
errorText: _confirmPasswordError,
|
||||
onChanged: (_) => setState(
|
||||
() => _confirmPasswordError = _validateConfirmPassword(),
|
||||
),
|
||||
onToggleObscure: () => setState(
|
||||
() => _obscureConfirmPassword = !_obscureConfirmPassword,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 25.h),
|
||||
|
||||
// 协议勾选
|
||||
_buildAgreementRow(),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// 注册按钮
|
||||
_buildRegisterButton(),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// 已有账号,去登录
|
||||
_buildFooterLink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 通用输入字段(带 label + 灰底圆角 + 错误提示)
|
||||
Widget _buildField({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
required String errorText,
|
||||
bool obscureText = false,
|
||||
ValueChanged<String>? onChanged,
|
||||
VoidCallback? onToggleObscure,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 输入框容器(灰底 #F9F9F9 + 圆角 24.r,对齐登录页)
|
||||
Container(
|
||||
height: 48.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF9F9F9),
|
||||
borderRadius: BorderRadius.circular(24.r),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// label 文字(Vue register-page__label 140rpx 宽)
|
||||
SizedBox(
|
||||
width: 70.w, // 140rpx → .w
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF323233),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF323233),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
hintText: hintText,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFFC0C4CC),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onToggleObscure != null)
|
||||
GestureDetector(
|
||||
onTap: onToggleObscure,
|
||||
child: Icon(
|
||||
obscureText
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
color: const Color(0xFFC0C4CC),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 错误提示:固定高度占位,有错误时显示文字,无错误时空白 → 输入框位置不动
|
||||
SizedBox(
|
||||
height: 22.h,
|
||||
child: errorText.isNotEmpty
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(left: 16.w, top: 6.h),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFFEE0A24),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgreementRow() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => _hasAgreed = !_hasAgreed),
|
||||
child: Icon(
|
||||
_hasAgreed
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 16.w,
|
||||
color: _hasAgreed
|
||||
? const Color(0xFF3D7DF5)
|
||||
: const Color(0xFFC0C4CC),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)),
|
||||
children: [
|
||||
const TextSpan(text: '我已阅读并同意'),
|
||||
TextSpan(
|
||||
text: '《用户协议》',
|
||||
style: const TextStyle(color: Color(0xFF3D7DF5)),
|
||||
recognizer: TapGestureRecognizer()..onTap = _goToAgreement,
|
||||
),
|
||||
const TextSpan(text: '和'),
|
||||
TextSpan(
|
||||
text: '《隐私政策》',
|
||||
style: const TextStyle(color: Color(0xFF3D7DF5)),
|
||||
recognizer: TapGestureRecognizer()..onTap = _goToPrivacy,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRegisterButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48.h,
|
||||
child: FilledButton(
|
||||
onPressed: _loading ? null : _handleRegister,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF3D7DF5),
|
||||
disabledBackgroundColor: _isFormValid
|
||||
? const Color(0xFF3D7DF5).withValues(alpha: 0.6)
|
||||
: const Color(0xFFC0C4CC), // 表单无效时整体灰色,对齐 Vue btn--disabled
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24.r),
|
||||
),
|
||||
),
|
||||
child: _loading
|
||||
? SizedBox(
|
||||
width: 18.w,
|
||||
height: 18.w,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'注册',
|
||||
style: TextStyle(fontSize: 18.sp, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooterLink() {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: GestureDetector(
|
||||
onTap: () => context.canPop() ? context.pop() : context.go('/login'),
|
||||
child: Text(
|
||||
'已有账号,去登录',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF3D7DF5)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/api/class/model.dart';
|
||||
import 'package:new_learn/core/components/image_preview_viewer.dart';
|
||||
import 'package:new_learn/core/components/resolve_problem_box.dart';
|
||||
import 'package:new_learn/core/components/subject_tag.dart';
|
||||
import 'package:new_learn/core/components/user_circle_avatar.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
@@ -236,35 +237,6 @@ class CardHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 学科标签(对标 SubjectTag)
|
||||
class SubjectTag extends StatelessWidget {
|
||||
const SubjectTag({super.key, required this.subject});
|
||||
|
||||
final String subject;
|
||||
|
||||
static const Map<String, _SubjectColor> _map = {
|
||||
'数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF),
|
||||
'物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF),
|
||||
'英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3),
|
||||
'化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3),
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = subject.trim();
|
||||
if (t.isEmpty) return const SizedBox.shrink();
|
||||
final c = _map[t] ?? const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF);
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c.bg),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(t, style: TextStyle(fontSize: 11, color: Color(c.fg))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 纯文字笔记(album-desc,最多 2 行)
|
||||
class AlbumDesc extends StatelessWidget {
|
||||
const AlbumDesc({super.key, required this.text});
|
||||
@@ -613,9 +585,3 @@ class _CommentInputState extends State<CommentInput> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubjectColor {
|
||||
const _SubjectColor(this.fg, this.bg);
|
||||
final int fg;
|
||||
final int bg;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
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/api/conquerPackage/beforeDuringClass/model.dart';
|
||||
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||
import 'package:new_learn/core/components/audio_player_card.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';
|
||||
|
||||
/// ============================================================
|
||||
/// 【BeforeDuringPage】单页讲义内容组件
|
||||
///
|
||||
/// 对标 Vue pages/conquerPackage/beforeDuringClass/beforeDuringPage.vue
|
||||
///
|
||||
/// 一、功能:
|
||||
/// 1. 显示讲义标题(name)
|
||||
/// 2. 渲染正文富文本(content → RichTextContent)
|
||||
/// 3. 渲染材料列表(materials):
|
||||
/// materialType=1 视频 → VideoPlayerView
|
||||
/// materialType=2 音频 → AudioPlayerCard
|
||||
/// materialType=3 文本 → RichTextContent
|
||||
/// 4. 底部 Upload 上传笔记组件(type=handouts)
|
||||
/// 5. 对外暴露方法:
|
||||
/// uploadLectureNotes() 触发选图上传
|
||||
/// getImageList() 获取当前笔记列表
|
||||
/// getIsUploading() 是否上传中/选图中
|
||||
/// pauseMedia() 暂停页面内所有媒体
|
||||
///
|
||||
/// 二、尺寸换算(本工程统一):
|
||||
/// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r
|
||||
///
|
||||
/// 三、与 Vue 的差异:
|
||||
/// - Flutter 端不维护 ref 数组来控制媒体暂停,
|
||||
/// 滑出视口时 dispose 自动释放资源(PageView.builder 懒加载特性)。
|
||||
/// - pauseMedia 方法保留为对外接口,通过 GlobalKey 调用时
|
||||
/// 遍历可见子树查找播放器组件来暂停(兼容手动触发场景)。
|
||||
/// ============================================================
|
||||
|
||||
class BeforeDuringPage extends StatefulWidget {
|
||||
const BeforeDuringPage({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.isCurrent = false,
|
||||
this.onUploadChanged,
|
||||
});
|
||||
|
||||
/// 讲义数据
|
||||
final HandoutItem data;
|
||||
|
||||
/// 是否为当前可见页(只有当前页才真正初始化视频解码器,避免硬件解码器占满)
|
||||
final bool isCurrent;
|
||||
|
||||
/// 笔记列表变化回调
|
||||
final ValueChanged<List<UploadItem>>? onUploadChanged;
|
||||
|
||||
@override
|
||||
State<BeforeDuringPage> createState() => BeforeDuringPageState();
|
||||
}
|
||||
|
||||
class BeforeDuringPageState extends State<BeforeDuringPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
final GlobalKey<UploadState> _uploadKey = GlobalKey<UploadState>();
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
// ===================== 对外暴露方法 =====================
|
||||
|
||||
/// 触发上传笔记
|
||||
void uploadLectureNotes() {
|
||||
_uploadKey.currentState?.chooseImage();
|
||||
}
|
||||
|
||||
/// 获取当前笔记列表
|
||||
List<UploadItem> getImageList() {
|
||||
return _uploadKey.currentState?.getImageList() ?? const [];
|
||||
}
|
||||
|
||||
/// 是否上传中或选图中
|
||||
bool getIsUploading() {
|
||||
final state = _uploadKey.currentState;
|
||||
if (state == null) return false;
|
||||
return state.uploadingCount > 0 || state.choosing;
|
||||
}
|
||||
|
||||
/// 暂停页面内所有媒体(视频/音频)
|
||||
void pauseMedia() {
|
||||
context.visitChildElements((element) {
|
||||
_tryPauseMedia(element);
|
||||
});
|
||||
}
|
||||
|
||||
void _tryPauseMedia(Element element) {
|
||||
// 通过 GlobalKey 形式找到 State 并调用 pause()
|
||||
if (element is StatefulElement) {
|
||||
final state = element.state;
|
||||
if (state is VideoPlayerViewState) {
|
||||
state.pause();
|
||||
return;
|
||||
}
|
||||
if (state is AudioPlayerCardState) {
|
||||
state.pause();
|
||||
return;
|
||||
}
|
||||
}
|
||||
element.visitChildren(_tryPauseMedia);
|
||||
}
|
||||
|
||||
// ===================== Build =====================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
left: 14.w,
|
||||
right: 14.w,
|
||||
top: 4.h,
|
||||
bottom: 90.h,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
if (widget.data.name.isNotEmpty) _buildTitle(),
|
||||
SizedBox(height: 10.h),
|
||||
// 正文富文本
|
||||
if (widget.data.content.isNotEmpty) _buildContent(),
|
||||
SizedBox(height: 10.h),
|
||||
// 材料列表
|
||||
if (widget.data.materials.isNotEmpty) _buildMaterials(),
|
||||
// 上传笔记
|
||||
_buildUpload(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 3.w,
|
||||
height: 14.h,
|
||||
margin: EdgeInsets.only(right: 7.5.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF3D7DF5),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.data.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||
child: RichTextContent(
|
||||
html: widget.data.content,
|
||||
renderMode: RenderMode.column,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF495A7A),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMaterials() {
|
||||
return Column(
|
||||
children: [
|
||||
for (final material in widget.data.materials)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: _buildMaterialItem(material),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMaterialItem(HandoutMaterial material) {
|
||||
switch (material.materialType) {
|
||||
case HandoutMaterialType.video:
|
||||
// 只有当前页才真正初始化视频解码器,避免硬件解码器占满导致 ExoPlaybackException
|
||||
if (!widget.isCurrent) {
|
||||
return _buildVideoPlaceholder(material);
|
||||
}
|
||||
return VideoPlayerView(
|
||||
url: material.materialContent,
|
||||
title: '讲义视频',
|
||||
autoPlay: false,
|
||||
);
|
||||
case HandoutMaterialType.audio:
|
||||
return AudioPlayerCard(
|
||||
url: material.materialContent,
|
||||
title: '讲义音频',
|
||||
);
|
||||
case HandoutMaterialType.text:
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||
child: RichTextContent(
|
||||
html: material.materialContent,
|
||||
renderMode: RenderMode.column,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF495A7A),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case HandoutMaterialType.unknown:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频占位图(非当前页展示,不占用硬件解码器)
|
||||
Widget _buildVideoPlaceholder(HandoutMaterial material) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 48.w,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUpload() {
|
||||
return Upload(
|
||||
key: _uploadKey,
|
||||
type: UploadType.handouts,
|
||||
ids: widget.data.id,
|
||||
initialImageList: widget.data.notes
|
||||
.map((note) => UploadItem.fromNoteImage(note))
|
||||
.toList(),
|
||||
onChanged: widget.onUploadChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
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';
|
||||
import 'package:new_learn/core/components/center_toast.dart';
|
||||
import 'package:new_learn/core/components/confirm_box.dart';
|
||||
import 'package:new_learn/core/components/upload.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/features/conquerPackage/beforeDuringClass/before_during_page.dart';
|
||||
|
||||
// ===================== 自定义滑动物理 =====================
|
||||
|
||||
/// 单向滑动控制:
|
||||
/// - 向右滑(上一页,index 减小)始终允许
|
||||
/// - 向左滑(下一页,index 增大)需要满足时长 + 笔记条件
|
||||
/// ============================================================
|
||||
/// 【BeforeDuringClassPage】课前课中(讲义学习)页面
|
||||
/// 对标 Vue pages/conquerPackage/beforeDuringClass/beforeDuringClass.vue
|
||||
///
|
||||
/// 一、页面结构:
|
||||
/// ┌──────────────────────────────────┐
|
||||
/// │ HeaderBac 背景图 + NavBar │
|
||||
/// │ ┌────────────────────────────┐ │
|
||||
/// │ │ 计时图标+时间 进度条 1/10 │ │
|
||||
/// │ └────────────────────────────┘ │
|
||||
/// ├──────────────────────────────────┤
|
||||
/// │ │
|
||||
/// │ PageView.builder (懒加载) │
|
||||
/// │ 每页一个 BeforeDuringPage │
|
||||
/// │ (仅构建视口 ±1 页,滑出回收) │
|
||||
/// │ │
|
||||
/// ├──────────────────────────────────┤
|
||||
/// │ 底部操作栏:上传笔记 / 上一步 / │
|
||||
/// │ 下一步(最后一页→完成学习) │
|
||||
/// └──────────────────────────────────┘
|
||||
///
|
||||
/// 二、核心功能:
|
||||
/// 1. PageView.builder 懒加载:physics 禁止滑动(按钮翻页),
|
||||
/// allowImplicitScrolling=true 预构建相邻页
|
||||
/// 2. 学习计时:Ticker 驱动,onPageChange 结算时长
|
||||
/// 3. 建议时长倒计时:当前页 planStudyTimeSecond 剩余时间
|
||||
/// 4. 笔记必传校验:isUploadNotes 页必须有笔记才能进下一页
|
||||
/// 5. 时长不足提示弹窗(可跳过)
|
||||
/// 6. 笔记必传提示弹窗
|
||||
///
|
||||
/// 三、尺寸换算(本工程统一):
|
||||
/// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r
|
||||
///
|
||||
/// 四、路由参数(通过 extra 传入):
|
||||
/// - courseId 课程 id
|
||||
/// - packId 攻克包 id
|
||||
/// - recordId 学习记录 id
|
||||
/// - type edit / view(默认 edit)
|
||||
/// - isWrong 是否错题模式(默认 false)
|
||||
/// ============================================================
|
||||
|
||||
/// 页面模式
|
||||
enum BeforeDuringMode { edit, view }
|
||||
|
||||
class BeforeDuringClassPage extends StatefulWidget {
|
||||
const BeforeDuringClassPage({super.key, this.extra});
|
||||
|
||||
final Map<String, dynamic>? extra;
|
||||
|
||||
@override
|
||||
State<BeforeDuringClassPage> createState() => _BeforeDuringClassPageState();
|
||||
}
|
||||
|
||||
class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
|
||||
with TickerProviderStateMixin {
|
||||
// ========== 颜色常量 ==========
|
||||
static const Color _bgColor = Color(0xFFF5F8FA);
|
||||
static const Color _titleColor = Color(0xFF1D2B47);
|
||||
static const Color _subtitleColor = Color(0xFF99A4BA);
|
||||
static const Color _primary = Color(0xFF3D7DF5);
|
||||
|
||||
// ========== Controller ==========
|
||||
final PageController _pageController = PageController();
|
||||
final Map<int, GlobalKey<BeforeDuringPageState>> _pageKeys = {};
|
||||
|
||||
// ========== 路由参数 ==========
|
||||
late BeforeDuringMode _mode;
|
||||
late String _courseId;
|
||||
late String _packId;
|
||||
late String _recordId;
|
||||
bool _isWrong = false;
|
||||
|
||||
// ========== 数据 ==========
|
||||
List<HandoutItem> _pageList = [];
|
||||
int _currentIndex = 0;
|
||||
bool _loading = true;
|
||||
|
||||
// ========== 全局学习计时 ==========
|
||||
late final Ticker _timingTicker;
|
||||
Duration _timingOffset = Duration.zero;
|
||||
Duration _timingElapsed = Duration.zero;
|
||||
bool _timingRunning = false;
|
||||
|
||||
// ========== 当前页倒计时 ==========
|
||||
late final Ticker _countdownTicker;
|
||||
Duration _countdownRemaining = Duration.zero;
|
||||
int _lastCountdownTickSecond = 0;
|
||||
DateTime _pageStartTime = DateTime.now();
|
||||
bool _isNavigatingAway = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_parseExtra();
|
||||
|
||||
// 全局学习计时
|
||||
_timingTicker = createTicker((elapsed) {
|
||||
if (_timingRunning) {
|
||||
_timingElapsed = elapsed + _timingOffset;
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
if (_mode == BeforeDuringMode.edit) {
|
||||
_timingTicker.start();
|
||||
_timingRunning = true;
|
||||
}
|
||||
|
||||
// 单页倒计时(基于真实流逝时间,每秒更新 UI)
|
||||
_countdownTicker = createTicker((elapsed) {
|
||||
final elapsedSeconds = elapsed.inSeconds;
|
||||
if (_lastCountdownTickSecond == elapsedSeconds) return;
|
||||
_lastCountdownTickSecond = elapsedSeconds;
|
||||
|
||||
if (_countdownRemaining.inSeconds > 0) {
|
||||
_countdownRemaining -= const Duration(seconds: 1);
|
||||
setState(() {});
|
||||
} else {
|
||||
_countdownTicker.stop();
|
||||
_markCurrentPagePlanTimeFinished();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadData();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timingTicker.dispose();
|
||||
_countdownTicker.dispose();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ===================== 初始化 =====================
|
||||
|
||||
void _parseExtra() {
|
||||
final extra = widget.extra ?? const <String, dynamic>{};
|
||||
_courseId = (extra['courseId']?.toString()) ?? '';
|
||||
_packId = (extra['packId']?.toString()) ?? '';
|
||||
_recordId = (extra['recordId']?.toString()) ?? '';
|
||||
_isWrong = extra['isWrong'] == true || extra['isWrong'] == 'true';
|
||||
final typeStr = (extra['type'] as String?) ?? 'edit';
|
||||
_mode = typeStr == 'view'
|
||||
? BeforeDuringMode.view
|
||||
: BeforeDuringMode.edit;
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final HandoutListResponse resp;
|
||||
if (_mode == BeforeDuringMode.view) {
|
||||
resp = await BeforeDuringClassApi.instance.getSubmitReport(_recordId);
|
||||
} else {
|
||||
resp = await BeforeDuringClassApi.instance
|
||||
.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,
|
||||
// isFinishPlanTime: false,
|
||||
// planStudyTimeSecond: 900,
|
||||
// studyTimeSecond: 0,
|
||||
// );
|
||||
// }).toList();
|
||||
// 测试:复制一份追加到数组末尾(翻倍页数)
|
||||
// _pageList.addAll(_pageList.map((item) => item.copyWith()).toList());
|
||||
|
||||
// 汇总已有学习时长
|
||||
if (_mode == BeforeDuringMode.edit) {
|
||||
final totalSeconds = _pageList.fold<int>(
|
||||
0,
|
||||
(acc, item) => acc + item.studyTimeSecond,
|
||||
);
|
||||
_timingOffset = Duration(seconds: totalSeconds);
|
||||
}
|
||||
|
||||
if (_pageList.isEmpty) {
|
||||
_goTransitionPage();
|
||||
return;
|
||||
}
|
||||
|
||||
_pageStartTime = DateTime.now();
|
||||
_startCountdownForCurrentPage();
|
||||
} catch (e) {
|
||||
debugPrint('[BeforeDuringClass] 加载失败: $e');
|
||||
if (mounted) {
|
||||
CenterToast.show(context, '加载失败,请重试');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 计时工具 =====================
|
||||
|
||||
String get _timingText => _formatDuration(_timingElapsed);
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
if (d.inHours > 0) {
|
||||
final h = d.inHours.toString().padLeft(2, '0');
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
// ===================== 倒计时 =====================
|
||||
|
||||
void _startCountdownForCurrentPage() {
|
||||
if (_mode == BeforeDuringMode.view) return;
|
||||
final page = _currentPage;
|
||||
if (page == null) return;
|
||||
if (page.planStudyTimeSecond <= 0 || page.isFinishPlanTime) {
|
||||
_countdownRemaining = Duration.zero;
|
||||
return;
|
||||
}
|
||||
final remain = page.planStudyRemainTimeSecond > 0
|
||||
? page.planStudyRemainTimeSecond
|
||||
: page.planStudyTimeSecond - page.studyTimeSecond;
|
||||
if (remain <= 0) {
|
||||
_markCurrentPagePlanTimeFinished();
|
||||
_countdownRemaining = Duration.zero;
|
||||
return;
|
||||
}
|
||||
_countdownRemaining = Duration(seconds: remain);
|
||||
_lastCountdownTickSecond = 0;
|
||||
if (!_countdownTicker.isActive) {
|
||||
_countdownTicker.start();
|
||||
}
|
||||
}
|
||||
|
||||
void _markCurrentPagePlanTimeFinished() {
|
||||
if (_currentIndex >= _pageList.length) return;
|
||||
final page = _pageList[_currentIndex];
|
||||
if (page.isFinishPlanTime) return;
|
||||
_pageList[_currentIndex] = page.copyWith(
|
||||
isFinishPlanTime: true,
|
||||
planStudyRemainTimeSecond: 0,
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _saveCurrentPageRemainTime() {
|
||||
if (_mode == BeforeDuringMode.view) return;
|
||||
if (_currentIndex >= _pageList.length) return;
|
||||
final page = _pageList[_currentIndex];
|
||||
if (page.planStudyTimeSecond <= 0 || page.isFinishPlanTime) return;
|
||||
final remain = _countdownRemaining.inSeconds;
|
||||
if (remain > 0 && remain <= page.planStudyTimeSecond) {
|
||||
_pageList[_currentIndex] =
|
||||
page.copyWith(planStudyRemainTimeSecond: remain);
|
||||
}
|
||||
}
|
||||
|
||||
String get _countdownText {
|
||||
final d = _countdownRemaining;
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
// ===================== 计算属性 =====================
|
||||
|
||||
int get _totalCount => _pageList.length;
|
||||
int get _currentLabel => _totalCount == 0 ? 0 : _currentIndex + 1;
|
||||
double get _percentage => _totalCount == 0 ? 0 : _currentLabel / _totalCount;
|
||||
|
||||
HandoutItem? get _currentPage =>
|
||||
_currentIndex < _pageList.length ? _pageList[_currentIndex] : null;
|
||||
|
||||
String get _title {
|
||||
if (_mode == BeforeDuringMode.view) {
|
||||
return _isWrong ? '错题解析' : '全部解析';
|
||||
}
|
||||
return _currentPage?.title ?? '';
|
||||
}
|
||||
|
||||
/// 最低时长是否未完成
|
||||
bool get _isTimeDisabled {
|
||||
if (_mode == BeforeDuringMode.view) return false;
|
||||
final p = _currentPage;
|
||||
if (p == null) return false;
|
||||
if (p.planStudyTimeSecond > 0 && !p.isFinishPlanTime) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 是否必须上传笔记但还没传
|
||||
bool get _isNoteDisabled {
|
||||
if (_mode == BeforeDuringMode.view) return false;
|
||||
final p = _currentPage;
|
||||
if (p == null) return false;
|
||||
if (p.isUploadNotes && p.notes.isEmpty) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 滑动到下一页是否被拦截(时长不足 或 笔记必传未传,都不能滑)
|
||||
bool get _isSwipeNextBlocked {
|
||||
if (_mode == BeforeDuringMode.view) return false;
|
||||
return _isTimeDisabled || _isNoteDisabled;
|
||||
}
|
||||
|
||||
/// 倒计时气泡是否显示
|
||||
bool get _showTimeBubble {
|
||||
if (_mode == BeforeDuringMode.view) return false;
|
||||
final p = _currentPage;
|
||||
if (p == null) return false;
|
||||
if (p.planStudyTimeSecond <= 0 || p.isFinishPlanTime) return false;
|
||||
return _countdownRemaining.inSeconds > 0;
|
||||
}
|
||||
|
||||
// ===================== 页面切换 =====================
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
if (index == _currentIndex) return;
|
||||
|
||||
_saveCurrentPageRemainTime();
|
||||
_pausePageMedia(_currentIndex);
|
||||
_renewHandoutTimeForIndex(_currentIndex);
|
||||
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
|
||||
_pageStartTime = DateTime.now();
|
||||
_startCountdownForCurrentPage();
|
||||
}
|
||||
|
||||
/// 手势滑动翻页(自己控制,不受 PageView physics 影响)
|
||||
/// - 向右滑(primaryVelocity > 0)→ 上一页,始终允许
|
||||
/// - 向左滑(primaryVelocity < 0)→ 下一页,需满足时长 + 笔记条件
|
||||
void _onHorizontalDragEnd(DragEndDetails details) {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
const kMinVelocity = 200.0; // 最小滑动速度阈值
|
||||
|
||||
if (velocity.abs() < kMinVelocity) return; // 滑动速度不够,忽略
|
||||
|
||||
if (velocity > 0) {
|
||||
// 向右滑 → 上一页
|
||||
if (_currentIndex > 0) {
|
||||
_pageController.animateToPage(
|
||||
_currentIndex - 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 向左滑 → 下一页(时长不足或笔记必传未传,都不能滑)
|
||||
if (_currentIndex < _pageList.length - 1 && !_isSwipeNextBlocked) {
|
||||
_pageController.animateToPage(
|
||||
_currentIndex + 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renewHandoutTimeForIndex(int index) async {
|
||||
if (_mode == BeforeDuringMode.view) return;
|
||||
if (index >= _pageList.length) return;
|
||||
final page = _pageList[index];
|
||||
if (page.id.isEmpty) return;
|
||||
try {
|
||||
await BeforeDuringClassApi.instance.renewHandoutTime(
|
||||
id: page.id,
|
||||
courseId: _courseId,
|
||||
startTime: _pageStartTime.toIso8601String(),
|
||||
endTime: DateTime.now().toIso8601String(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[BeforeDuringClass] RenewTime 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _pausePageMedia(int index) {
|
||||
_pageKeys[index]?.currentState?.pauseMedia();
|
||||
}
|
||||
|
||||
// ===================== 底部按钮操作 =====================
|
||||
|
||||
void _onUploadNotes() {
|
||||
_pageKeys[_currentIndex]?.currentState?.uploadLectureNotes();
|
||||
}
|
||||
|
||||
Future<void> _onPrevious() async {
|
||||
if (_currentIndex == 0) return;
|
||||
_saveCurrentPageRemainTime();
|
||||
_pausePageMedia(_currentIndex);
|
||||
_pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onNext() async {
|
||||
// 上传中禁止跳转
|
||||
final currentState = _pageKeys[_currentIndex]?.currentState;
|
||||
if (currentState != null && currentState.getIsUploading()) {
|
||||
CenterToast.show(context, '图片上传中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 时长不足(可跳过)
|
||||
if (_isTimeDisabled) {
|
||||
final timeText = _formatDuration(
|
||||
Duration(seconds: _currentPage?.planStudyTimeSecond ?? 0),
|
||||
);
|
||||
final ok = await showConfirmBox(
|
||||
context,
|
||||
title: '建议学习时长 $timeText',
|
||||
content: '仓促速学掌握不牢,之后还要重新复习\n本次攻克成功,学习时长会逐步减少',
|
||||
cancelText: '继续学习',
|
||||
confirmText: '跳过',
|
||||
);
|
||||
if (ok != true) return;
|
||||
}
|
||||
|
||||
// 笔记必传(不可跳过,放在时长之后,点跳过时长后仍会检查)
|
||||
if (_isNoteDisabled) {
|
||||
final ok = await showConfirmBox(
|
||||
context,
|
||||
title: '该讲义必须上传笔记',
|
||||
content: '上传笔记,有助于更好记录课程内容,提升学习效率',
|
||||
cancelText: '取消',
|
||||
confirmText: '上传笔记',
|
||||
);
|
||||
if (ok == true) {
|
||||
_onUploadNotes();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 最后一页 → 完成学习
|
||||
if (_currentIndex == _pageList.length - 1) {
|
||||
if (_mode == BeforeDuringMode.edit) {
|
||||
await _finishAll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_saveCurrentPageRemainTime();
|
||||
_pausePageMedia(_currentIndex);
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _finishAll() async {
|
||||
_isNavigatingAway = true;
|
||||
_saveCurrentPageRemainTime();
|
||||
_pausePageMedia(_currentIndex);
|
||||
await _renewHandoutTimeForIndex(_currentIndex);
|
||||
if (mounted) {
|
||||
_goTransitionPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _goTransitionPage() {
|
||||
// 判断下一个状态:课前(1)→课中(2)→课后(3)
|
||||
// 此处简化:完成学习后默认进入课后练习过渡页(status=3)
|
||||
context.push(
|
||||
'/conquerPackage/transitionPage',
|
||||
extra: {
|
||||
'courseId': _courseId,
|
||||
'packId': _packId,
|
||||
'recordId': _recordId,
|
||||
'status': '3',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 笔记变化 =====================
|
||||
|
||||
void _onUploadChanged(List<UploadItem> list) {
|
||||
if (_currentIndex >= _pageList.length) return;
|
||||
final page = _pageList[_currentIndex];
|
||||
_pageList[_currentIndex] = page.copyWith(
|
||||
notes: list
|
||||
.where((item) => item.id != null)
|
||||
.map((item) => NoteImage(
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
// ===================== 返回 =====================
|
||||
|
||||
Future<bool> _onWillPop() async {
|
||||
if (_mode == BeforeDuringMode.edit && !_isNavigatingAway) {
|
||||
_saveCurrentPageRemainTime();
|
||||
await _renewHandoutTimeForIndex(_currentIndex);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===================== Build =====================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvoked: (didPop) async {
|
||||
if (didPop) return;
|
||||
await _onWillPop();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: _bgColor,
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _pageList.isEmpty
|
||||
? const Center(child: Text('暂无讲义'))
|
||||
: Stack(
|
||||
children: [
|
||||
// 顶部背景图
|
||||
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(),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 导航栏
|
||||
_buildNavBar(),
|
||||
// 计时 + 进度
|
||||
_buildHeader(),
|
||||
// 主体(外层手势识别,自己控制左右滑动)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onHorizontalDragEnd: _onHorizontalDragEnd,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _pageList.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
allowImplicitScrolling: true,
|
||||
itemBuilder: (context, index) {
|
||||
_pageKeys[index] ??=
|
||||
GlobalKey<BeforeDuringPageState>();
|
||||
return BeforeDuringPage(
|
||||
key: _pageKeys[index],
|
||||
data: _pageList[index],
|
||||
isCurrent: index == _currentIndex,
|
||||
onUploadChanged: _onUploadChanged,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部操作栏
|
||||
_buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== NavBar =====================
|
||||
|
||||
Widget _buildNavBar() {
|
||||
return SizedBox(
|
||||
height: 44.h,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
_title,
|
||||
style: const TextStyle(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.8),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16.w,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios,
|
||||
color: Color.fromRGBO(0, 0, 0, 0.8), size: 20),
|
||||
onPressed: () async {
|
||||
await _onWillPop();
|
||||
if (mounted) Navigator.of(context).maybePop();
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== Header(计时 + 进度) =====================
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(14.w, 4.h, 14.w, 10.h),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧:计时
|
||||
_buildTiming(),
|
||||
const Spacer(),
|
||||
// 右侧:进度条 + 页码
|
||||
_buildProgress(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTiming() {
|
||||
if (_mode == BeforeDuringMode.view) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/timing.png'),
|
||||
width: 18.w,
|
||||
height: 18.w,
|
||||
errorWidget: (_, __, ___) =>
|
||||
Icon(Icons.timer, size: 18.w, color: _primary),
|
||||
),
|
||||
SizedBox(width: 5.w),
|
||||
Text(
|
||||
_timingText,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: _titleColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgress() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 150.w,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
child: LinearProgressIndicator(
|
||||
value: _percentage,
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white,
|
||||
valueColor: const AlwaysStoppedAnimation(_primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$_currentLabel',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _titleColor,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: '/$_totalCount',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: _subtitleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== Footer =====================
|
||||
|
||||
Widget _buildFooter() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 上传笔记按钮
|
||||
if (_mode == BeforeDuringMode.edit) _buildUploadNoteButton(),
|
||||
if (_mode == BeforeDuringMode.edit) SizedBox(width: 10.w),
|
||||
// 上一步
|
||||
Expanded(
|
||||
child: _buildStepButton(
|
||||
text: '上一步',
|
||||
disabled: _currentIndex == 0,
|
||||
onTap: _onPrevious,
|
||||
isPrimary: false,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
// 下一步
|
||||
Expanded(
|
||||
child: _buildNextButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadNoteButton() {
|
||||
return GestureDetector(
|
||||
onTap: _onUploadNotes,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/note.png'),
|
||||
width: 22.w,
|
||||
height: 22.w,
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.note_add_outlined,
|
||||
size: 22.w,
|
||||
color: _primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3.h),
|
||||
Text(
|
||||
'上传笔记',
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
color: _titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepButton({
|
||||
required String text,
|
||||
required bool disabled,
|
||||
required VoidCallback onTap,
|
||||
required bool isPrimary,
|
||||
}) {
|
||||
final bgColor = isPrimary ? _primary : const Color(0xFFF0F2F5);
|
||||
final textColor = isPrimary ? Colors.white : _titleColor;
|
||||
|
||||
return Opacity(
|
||||
opacity: disabled ? 0.5 : 1.0,
|
||||
child: FilledButton(
|
||||
onPressed: disabled ? null : onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: bgColor,
|
||||
foregroundColor: textColor,
|
||||
disabledBackgroundColor: bgColor,
|
||||
disabledForegroundColor: textColor,
|
||||
minimumSize: Size.fromHeight(47.h),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
child: Text(text),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNextButton() {
|
||||
final isLast = _currentIndex == _pageList.length - 1;
|
||||
final text = isLast && _mode == BeforeDuringMode.edit
|
||||
? '完成学习'
|
||||
: '下一步';
|
||||
// 只有 view 模式最后一页才禁用;edit 模式永远可点(点了弹引导弹窗)
|
||||
final disabled = _mode == BeforeDuringMode.view && isLast;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_buildStepButton(
|
||||
text: text,
|
||||
disabled: disabled,
|
||||
onTap: _onNext,
|
||||
isPrimary: true,
|
||||
),
|
||||
if (_showTimeBubble)
|
||||
Positioned(
|
||||
right: 4.w,
|
||||
top: -12.h,
|
||||
child: Container(
|
||||
height: 20.h,
|
||||
padding: EdgeInsets.fromLTRB(8.w, 0, 8.w, 3.h),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
ImgUrl.build('/StaticResource/2026-04-17/bubble.png'),
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -1.h),
|
||||
child: Text(
|
||||
_countdownText,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
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
|
||||
///
|
||||
/// 入参:courseId(课程 id)
|
||||
///
|
||||
/// 接口:
|
||||
/// - CaptureListApi.getCoursePack(courseId) → 列表 + remark
|
||||
/// - CaptureListApi.startOvercome(packId) → 开始/继续攻克,跳转对应页面
|
||||
///
|
||||
/// 换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r`
|
||||
/// ============================================================
|
||||
|
||||
/// 攻克记录状态枚举(与后端 status 对齐:0未开始/1成功/2失败/3判题中)
|
||||
enum CaptureRecordStatus {
|
||||
none(0), // 未开始
|
||||
success(1), // 成功
|
||||
fail(2), // 失败
|
||||
processing(3); // 判题中
|
||||
|
||||
final int value;
|
||||
const CaptureRecordStatus(this.value);
|
||||
|
||||
factory CaptureRecordStatus.fromInt(int? v) {
|
||||
return CaptureRecordStatus.values.firstWhere(
|
||||
(e) => e.value == v,
|
||||
orElse: () => CaptureRecordStatus.none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 攻克包状态枚举(与后端 status 对齐)
|
||||
enum CapturePackStatus {
|
||||
notStarted(0), // 未开始
|
||||
inProgress(1), // 进行中
|
||||
conquered(2), // 已攻克
|
||||
failed(3), // 攻克失败
|
||||
laterStudy(4), // 后续学习
|
||||
judging(5); // 判题中
|
||||
|
||||
final int value;
|
||||
const CapturePackStatus(this.value);
|
||||
|
||||
factory CapturePackStatus.fromInt(int? v) {
|
||||
return CapturePackStatus.values.firstWhere(
|
||||
(e) => e.value == v,
|
||||
orElse: () => CapturePackStatus.notStarted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面内部包装模型(在 CourseInfo 基础上增加 UI 状态 isShowCaptureInfo)
|
||||
class _CapturePackViewModel {
|
||||
final CourseInfo info;
|
||||
bool isShowCaptureInfo;
|
||||
|
||||
_CapturePackViewModel(this.info, {this.isShowCaptureInfo = false});
|
||||
|
||||
String get id => '${info.id}';
|
||||
String get name => info.name;
|
||||
String get baseSubjectName => info.baseSubjectName;
|
||||
int get overcomeStandard => info.overcomeStandard;
|
||||
int get maxOvercomeCount => info.maxOvercomeCount;
|
||||
int get recordCount => info.recordCount;
|
||||
CapturePackStatus get status => CapturePackStatus.fromInt(info.status);
|
||||
List<CourseRecord> get records => info.records;
|
||||
}
|
||||
|
||||
class CaptureListPage extends StatefulWidget {
|
||||
const CaptureListPage({super.key, required this.courseId});
|
||||
|
||||
final String courseId;
|
||||
|
||||
@override
|
||||
State<CaptureListPage> createState() => _CaptureListPageState();
|
||||
}
|
||||
|
||||
class _CaptureListPageState extends State<CaptureListPage> {
|
||||
// ========== 颜色常量 ==========
|
||||
static const Color _bgColor = Color(0xFFF5F8FA);
|
||||
static const Color _titleColor = Color(0xFF1D2B47);
|
||||
static const Color _subtitleColor = Color(0xFF495A7A);
|
||||
static const Color _primary = Color(0xFF3D7DF5);
|
||||
static const Color _success = Color(0xFF00CC96);
|
||||
static const Color _error = Color(0xFFFF4D4D);
|
||||
static const Color _processing = Color(0xFFF5963D);
|
||||
static const Color _barBg = Color(0xFFE3ECFF);
|
||||
static const Color _mutedText = Color(0xFF687FAD);
|
||||
|
||||
// ========== 状态 ==========
|
||||
String _remark = '';
|
||||
bool _navigating = false; // 防止连续点击重复请求/跳转
|
||||
List<String> _expandedPackIds = []; // 展开的包 id(刷新时保留展开状态)
|
||||
int _requestId = 0; // 请求序号,防止并发请求错乱
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
// ========== 接口 ==========
|
||||
|
||||
/// 分页加载:CommonPagedList 回调。接口无分页,当作一页返回,hasNext=false。
|
||||
Future<PagingResult<_CapturePackViewModel>> _loadPage(int pageKey) async {
|
||||
final requestId = ++_requestId;
|
||||
final expandedIds = _expandedPackIds.isNotEmpty
|
||||
? [..._expandedPackIds]
|
||||
: <String>[];
|
||||
|
||||
// courseId 为空直接返回空列表
|
||||
if (widget.courseId.isEmpty) {
|
||||
if (!mounted) return const PagingResult(data: [], hasNext: false);
|
||||
setState(() => _remark = '');
|
||||
return const PagingResult(data: [], hasNext: false);
|
||||
}
|
||||
|
||||
final res = await CaptureListApi.instance.getCoursePack(widget.courseId);
|
||||
if (requestId != _requestId) {
|
||||
// 已被新请求覆盖,丢弃结果(返回空让库忽略也可)
|
||||
return const PagingResult(data: [], hasNext: false);
|
||||
}
|
||||
|
||||
final list = _restoreExpandedState(res.packs, expandedIds);
|
||||
if (!mounted) return PagingResult(data: list, hasNext: false);
|
||||
|
||||
setState(() {
|
||||
_remark = res.remark;
|
||||
_expandedPackIds = list
|
||||
.where((item) => item.isShowCaptureInfo)
|
||||
.map((item) => item.id)
|
||||
.toList();
|
||||
});
|
||||
|
||||
// 只有一页
|
||||
return PagingResult(data: list, hasNext: false);
|
||||
}
|
||||
|
||||
// ========== 展开状态管理(对标 Vue saveExpandedState / restoreExpandedState) ==========
|
||||
|
||||
/// 恢复展开状态 + 计算 recordCount(status != 0 的记录数)
|
||||
List<_CapturePackViewModel> _restoreExpandedState(
|
||||
List<CourseInfo> list,
|
||||
List<String> expandedIds,
|
||||
) {
|
||||
final expandedIdSet = expandedIds.toSet();
|
||||
return list.map((info) {
|
||||
final records = info.records;
|
||||
final validCount = records.where((r) => r.status != 0).length;
|
||||
// 构造一个新的 CourseInfo(recordCount 取有效记录数)
|
||||
final adjustedInfo = CourseInfo(
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
baseSubjectName: info.baseSubjectName,
|
||||
overcomeStandard: info.overcomeStandard,
|
||||
estimatedTime: info.estimatedTime,
|
||||
maxOvercomeCount: info.maxOvercomeCount,
|
||||
recordCount: validCount,
|
||||
records: records,
|
||||
status: info.status,
|
||||
statusName: info.statusName,
|
||||
);
|
||||
final hasValidRecords = records.any((r) =>
|
||||
r.status == 1 || r.status == 2 || r.status == 3);
|
||||
final shouldExpand =
|
||||
expandedIdSet.contains('${info.id}') || hasValidRecords;
|
||||
return _CapturePackViewModel(adjustedInfo,
|
||||
isShowCaptureInfo: shouldExpand);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 切换攻克详情展开/收起
|
||||
void _toggleCaptureInfo(_CapturePackViewModel item) {
|
||||
if (item.records.isEmpty) return;
|
||||
final validRecords = item.records.where((r) => r.status != 0).toList();
|
||||
if (validRecords.isEmpty) return;
|
||||
setState(() {
|
||||
item.isShowCaptureInfo = !item.isShowCaptureInfo;
|
||||
if (item.isShowCaptureInfo) {
|
||||
if (!_expandedPackIds.contains(item.id)) {
|
||||
_expandedPackIds.add(item.id);
|
||||
}
|
||||
} else {
|
||||
_expandedPackIds.remove(item.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 点击事件 ==========
|
||||
|
||||
/// 点击按钮 → 开始/继续攻克(对接 startOvercome 接口)
|
||||
Future<void> _navigateToCaptureInfo(_CapturePackViewModel item) async {
|
||||
if (item.status == CapturePackStatus.judging) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('该攻克正在判题中,暂不能操作'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_navigating) return;
|
||||
_navigating = true;
|
||||
|
||||
// 显示加载
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
|
||||
try {
|
||||
final res = await CaptureListApi.instance.startOvercome(item.id);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(); // 关闭加载
|
||||
|
||||
// 跳转分支(对标 Vue navigateToCaptureInfo)
|
||||
if (res.hasHandout) {
|
||||
// 有讲义 → 课前课中页
|
||||
_navigateTo('/conquerPackage/beforeDuringClass', {
|
||||
'status': '1',
|
||||
'packId': item.id,
|
||||
'courseId': widget.courseId,
|
||||
'recordId': '${res.recordId}',
|
||||
if (res.nowStudyObjectType == 11)
|
||||
'nowStudyObjectId': '${res.nowStudyObjectId}',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res.hasQuestion) {
|
||||
// 有题目 → 过渡页 或 习题页
|
||||
final pagePath = res.nowStudyObjectType == 0
|
||||
? '/conquerPackage/transitionPage'
|
||||
: '/conquerPackage/classExercises';
|
||||
_navigateTo(pagePath, {
|
||||
'status': '3',
|
||||
'courseId': widget.courseId,
|
||||
'packId': item.id,
|
||||
'recordId': '${res.recordId}',
|
||||
if (res.nowStudyObjectType == 12)
|
||||
'nowStudyObjectId': '${res.nowStudyObjectId}',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 既没讲义也没题目
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('该攻克暂无讲义或题目'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // 关闭加载
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('加载失败:${e.toString()}'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_navigating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面跳转(go_router push + extra 传参)
|
||||
void _navigateTo(String path, Map<String, String> params) {
|
||||
// TODO(路由): 替换为项目统一的路由跳转方式
|
||||
// debugPrint('navigateTo: $path $params');
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
|
||||
// );
|
||||
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;
|
||||
_navigateTo('/conquerPackage/report', {
|
||||
'recordId': '${record.id}',
|
||||
'packId': pack.id,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 工具方法 ==========
|
||||
|
||||
String _numberToChinese(int num) {
|
||||
const chineseNum = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
|
||||
return chineseNum[num.clamp(0, 9)];
|
||||
}
|
||||
|
||||
// ========== 构建 ==========
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _bgColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: ColoredBox(color: _bgColor)),
|
||||
// 顶部背景图
|
||||
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: (context, url) => const SizedBox.shrink(),
|
||||
errorWidget: (context, url, error) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
// 主内容
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 导航栏
|
||||
SizedBox(
|
||||
height: 44.h,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Center(
|
||||
child: Text(
|
||||
'攻克列表',
|
||||
style: TextStyle(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.8),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16.w,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios,
|
||||
color: Color.fromRGBO(0, 0, 0, 0.8), size: 20),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 规则卡片
|
||||
_buildRuleView(),
|
||||
SizedBox(height: 19.h),
|
||||
// 列表
|
||||
Expanded(
|
||||
child: CommonPagedList<_CapturePackViewModel>(
|
||||
load: _loadPage,
|
||||
buildItem: (context, item, index) => _buildCaptureItem(item),
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
emptyWidget: _buildEmpty(),
|
||||
noMoreText: '', // 只有一页,不显示"没有更多"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 子组件构建方法
|
||||
// ============================================================
|
||||
|
||||
Widget _buildRuleView() {
|
||||
return Container(
|
||||
margin: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 0),
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
border: Border.all(color: Colors.white, width: 1.w),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color.fromARGB(255, 255, 255, 255).withOpacity(0.05),
|
||||
blurRadius: 20.r,
|
||||
offset: Offset(0, 10.h),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRuleTitle(),
|
||||
SizedBox(height: 16.h),
|
||||
_buildRuleContent(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRuleTitle() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build('/StaticResource/2026-04-10/capture-list-rule.png'),
|
||||
width: 18.w,
|
||||
height: 18.w,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 2.h),
|
||||
child: Text(
|
||||
'攻克规则说明',
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _titleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -1.h,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 59.5.w,
|
||||
height: 9.h,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0x003D7DF5),
|
||||
Color(0x331D9CFF),
|
||||
Color(0x808AECFD),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(50.r),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRuleContent() {
|
||||
if (_remark.isEmpty) {
|
||||
return Text('暂无规则说明',
|
||||
style: TextStyle(fontSize: 14.sp, color: _subtitleColor));
|
||||
}
|
||||
final lines = _remark.split('\n');
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: lines.asMap().entries.map((entry) {
|
||||
final isLast = entry.key == lines.length - 1;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: isLast ? 0 : 10.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 4.w,
|
||||
height: 4.w,
|
||||
margin: EdgeInsets.fromLTRB(6.5.w, 7.5.h, 13.w, 0),
|
||||
decoration: const BoxDecoration(
|
||||
color: _primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: TextStyle(fontSize: 14.sp, color: _subtitleColor, height: 1.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build('/StaticResource/2026-04-11/noCoursesss.png'),
|
||||
width: 200.w,
|
||||
height: 200.w,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Text('暂无攻克', style: TextStyle(fontSize: 14.sp, color: _mutedText)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaptureItem(_CapturePackViewModel item) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(bottom: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
border: Border.all(color: Colors.white, width: 1.w),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFAFEFF), Color(0xFFEDF7FF)],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildCaptureInfo(item),
|
||||
_buildCaptureProgress(item),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaptureInfo(_CapturePackViewModel item) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(14.w, 18.5.h, 14.w, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCaptureTitle(item.name),
|
||||
SizedBox(height: 10.h),
|
||||
_buildSubjectTag(item.baseSubjectName),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
_buildStandardBox(item.overcomeStandard),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaptureTitle(String name) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 3.w,
|
||||
height: 14.h,
|
||||
margin: EdgeInsets.only(right: 7.5.w),
|
||||
decoration: BoxDecoration(
|
||||
color: _primary,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _titleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubjectTag(String subject) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 1.w, vertical: 1.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF5A9EFF), Color(0xFFF2F8FF), Color(0xFF5A9EFF)],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F0FF),
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
),
|
||||
child: Text(
|
||||
subject,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: _primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStandardBox(int standard) {
|
||||
return Container(
|
||||
width: 60.w,
|
||||
height: 51.h,
|
||||
padding: EdgeInsets.all(2.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF60D0C0), Color(0xFF6EE9DB)],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'攻克标准',
|
||||
style: TextStyle(fontSize: 12.sp, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.fromLTRB(1.5.w, 0, 1.5.w, 1.5.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(6.r),
|
||||
bottomRight: Radius.circular(6.r),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl:
|
||||
ImgUrl.build('/StaticResource/2026-04-10/capture-list-success.png'),
|
||||
width: 14.w,
|
||||
height: 14.w,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
SizedBox(width: 5.5.w),
|
||||
Text(
|
||||
'$standard%',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp, color: _success, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaptureProgress(_CapturePackViewModel item) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: 10.5.h),
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildProgressTop(item),
|
||||
if (item.isShowCaptureInfo) _buildProgressInfo(item),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressTop(_CapturePackViewModel item) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _toggleCaptureInfo(item),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
children: [
|
||||
Text('攻克次数', style: TextStyle(fontSize: 12.sp, color: _mutedText)),
|
||||
SizedBox(width: 10.w),
|
||||
_buildProgressBars(item),
|
||||
SizedBox(width: 10.w),
|
||||
_buildProgressCount(item),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildButtonGroup(item),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressBars(_CapturePackViewModel item) {
|
||||
return Row(
|
||||
children: List.generate(item.maxOvercomeCount, (idx) {
|
||||
final record = idx < item.records.length ? item.records[idx] : null;
|
||||
Color color;
|
||||
switch (CaptureRecordStatus.fromInt(record?.status)) {
|
||||
case CaptureRecordStatus.success:
|
||||
color = _success;
|
||||
break;
|
||||
case CaptureRecordStatus.fail:
|
||||
color = _error;
|
||||
break;
|
||||
case CaptureRecordStatus.processing:
|
||||
color = _processing;
|
||||
break;
|
||||
default:
|
||||
color = _barBg;
|
||||
}
|
||||
return Container(
|
||||
width: 20.w,
|
||||
height: 4.h,
|
||||
margin: EdgeInsets.only(right: idx == item.maxOvercomeCount - 1 ? 0 : 4.w),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCount(_CapturePackViewModel item) {
|
||||
final hasRecords = item.recordCount > 0 && item.records.isNotEmpty;
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
'${item.recordCount}/${item.maxOvercomeCount}',
|
||||
style: TextStyle(fontSize: 12.sp, color: _mutedText),
|
||||
),
|
||||
if (hasRecords)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 4.5.w),
|
||||
child: Icon(
|
||||
item.isShowCaptureInfo
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 12.sp,
|
||||
color: _mutedText,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButtonGroup(_CapturePackViewModel item) {
|
||||
switch (item.status) {
|
||||
case CapturePackStatus.notStarted:
|
||||
return _buildSolidButton('开始攻克', () => _navigateToCaptureInfo(item));
|
||||
case CapturePackStatus.inProgress:
|
||||
final text = item.records.isNotEmpty ? '继续攻克' : '开始攻克';
|
||||
return _buildSolidButton(text, () => _navigateToCaptureInfo(item));
|
||||
case CapturePackStatus.conquered:
|
||||
return _buildOutlineButton('已攻克', _success, null, opacity: 0.5);
|
||||
case CapturePackStatus.failed:
|
||||
return _buildOutlineButton('攻克失败', _error,
|
||||
() => _navigateToCaptureInfo(item),
|
||||
opacity: 0.5);
|
||||
case CapturePackStatus.laterStudy:
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToCaptureInfo(item),
|
||||
child: Text(
|
||||
'后续学习',
|
||||
style: TextStyle(fontSize: 11.sp, color: _primary),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
_buildSolidButton('重新攻克', () => _navigateToCaptureInfo(item)),
|
||||
],
|
||||
);
|
||||
case CapturePackStatus.judging:
|
||||
return _buildOutlineButton('判题中', _success, null, opacity: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildOutlineButton(String text, Color color, VoidCallback? onTap,
|
||||
{double opacity = 1.0}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
|
||||
height: 30.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 1.w),
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
color: onTap == null ? Colors.transparent : color.withOpacity(0.08),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 13.sp, color: color, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSolidButton(String text, VoidCallback? onTap,
|
||||
{double opacity = 1.0}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
|
||||
height: 30.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
color: _primary,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp, color: Colors.white, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressInfo(_CapturePackViewModel item) {
|
||||
final validRecords =
|
||||
item.records.where((r) => r.status != 0).toList();
|
||||
if (validRecords.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: 16.h),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.5.h),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFBFD),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
border: Border.all(color: _barBg, width: 0.5.w),
|
||||
),
|
||||
child: Column(
|
||||
children: validRecords.asMap().entries.map((entry) {
|
||||
final infoIndex = entry.key;
|
||||
final record = entry.value;
|
||||
return GestureDetector(
|
||||
onTap: () => _getReport(record, item),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 5.h),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'第${_numberToChinese(infoIndex + 1)}次攻克',
|
||||
style: TextStyle(fontSize: 12.sp, color: _mutedText),
|
||||
),
|
||||
SizedBox(width: 13.w),
|
||||
Text(
|
||||
formatDuration(record.overcomeTimeSecond),
|
||||
style: TextStyle(fontSize: 12.sp, color: _subtitleColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (CaptureRecordStatus.fromInt(record.status) ==
|
||||
CaptureRecordStatus.processing)
|
||||
Text('判题中',
|
||||
style: TextStyle(fontSize: 12.sp, color: _processing))
|
||||
else
|
||||
_buildInfoRight(record, item),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRight(CourseRecord record, _CapturePackViewModel pack) {
|
||||
final isSuccess = record.accuracyRate >= pack.overcomeStandard;
|
||||
final bgColor =
|
||||
isSuccess ? const Color(0xFFE8FAF6) : const Color(0xFFFFF0F0);
|
||||
final textColor = isSuccess ? _success : _error;
|
||||
final iconPath = isSuccess
|
||||
? '/StaticResource/2026-04-22/capture-list-success.png'
|
||||
: '/StaticResource/2026-04-22/capture-list-fail.png';
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.5.h),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(iconPath),
|
||||
width: 14.w,
|
||||
height: 14.w,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
SizedBox(width: 5.5.w),
|
||||
Text(
|
||||
'${record.accuracyRate}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp, color: textColor, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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(' ', ' ');
|
||||
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'^(?: |\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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
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/api/exam/api.dart';
|
||||
import 'package:new_learn/core/api/exam/model.dart';
|
||||
import 'package:new_learn/core/components/common_paged_list.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/subject_icons.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ExamPage】我的考试列表页 —— 对标 Vue pages/myPackage/exam/exam.vue
|
||||
///
|
||||
/// 结构:
|
||||
/// AppBar + Tab 栏(全部/各学科,胶囊样式,动态拉取)
|
||||
/// + 分页列表(CommonPagedList,下拉刷新/触底加载)
|
||||
/// + 卡片(学科图标 + 考试名 + 时间 + 得分渐变文字)
|
||||
///
|
||||
/// 点击卡片 → 跳考试报告详情
|
||||
/// (先留 TODO,后续考试详情页做了再接入路由)
|
||||
/// ============================================================
|
||||
class ExamPage extends StatefulWidget {
|
||||
const ExamPage({super.key});
|
||||
|
||||
@override
|
||||
State<ExamPage> createState() => _ExamPageState();
|
||||
}
|
||||
|
||||
class _ExamPageState extends State<ExamPage> {
|
||||
final List<ExamSubjectTab> _tabs = [];
|
||||
ExamSubjectTab _currentTab = const ExamSubjectTab(id: null, name: '全部');
|
||||
bool _tabsLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabs.add(const ExamSubjectTab(id: null, name: '全部'));
|
||||
_loadTabs();
|
||||
}
|
||||
|
||||
Future<void> _loadTabs() async {
|
||||
try {
|
||||
final list = await ExamApi.instance.getSubjects();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tabs
|
||||
..clear()
|
||||
..add(const ExamSubjectTab(id: null, name: '全部'))
|
||||
..addAll(list);
|
||||
_currentTab = _tabs.first;
|
||||
_tabsLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
talker.error('获取考试科目Tab失败', e);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tabs
|
||||
..clear()
|
||||
..add(const ExamSubjectTab(id: null, name: '全部'));
|
||||
_currentTab = _tabs.first;
|
||||
_tabsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页加载回调
|
||||
Future<PagingResult<ExamDto>> _loadPage(int pageKey) async {
|
||||
final res = await ExamApi.instance.search(
|
||||
baseSubjectId: _currentTab.id,
|
||||
pageIndex: pageKey,
|
||||
pageSize: 10,
|
||||
);
|
||||
// hasNext:当前页返回数据满 pageSize 且总数更大
|
||||
final hasMore =
|
||||
res.data.length >= res.pageSize && res.total > pageKey * res.pageSize;
|
||||
return PagingResult(data: res.data, hasNext: hasMore);
|
||||
}
|
||||
|
||||
void _onTapTab(ExamSubjectTab tab) {
|
||||
if (_currentTab.id == tab.id) return;
|
||||
setState(() => _currentTab = tab);
|
||||
// tab 切换后,通过 ValueKey 让 CommonPagedList 重建 State,自动首屏加载新数据
|
||||
}
|
||||
|
||||
void _goToExamDetail(ExamDto item) {
|
||||
// TODO: 接入考试报告详情页路由,对应 Vue /pages/examPackage/examReport/index?recordId=${item.id}
|
||||
talker.debug('点击考试详情: ${item.taskName}, id=${item.id}');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text('我的考试'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1D2B47),
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(44.h),
|
||||
child: _buildTabBar(),
|
||||
),
|
||||
),
|
||||
body: _tabsLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Container(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
child: CommonPagedList<ExamDto>(
|
||||
key: ValueKey('exam-list-${_currentTab.id}'),
|
||||
load: _loadPage,
|
||||
padding: EdgeInsets.fromLTRB(12.w, 24.h, 12.w, 0),
|
||||
// separatorBuilder: (_, __) => SizedBox(height: 14.h),
|
||||
noMoreText: '- AI 爱学蝶变提醒你 没有更多了 -',
|
||||
emptyWidget: _buildEmpty(),
|
||||
buildItem: (ctx, item, index) {
|
||||
return _ExamCard(
|
||||
item: item,
|
||||
onTap: () => _goToExamDetail(item),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Tab 栏(对标 up-tabs + 自定义 activeStyle/inactiveStyle)=====
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 12.h),
|
||||
color: Colors.white,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Row(
|
||||
children: _tabs.map((tab) {
|
||||
final isActive = tab.id == _currentTab.id;
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w),
|
||||
child: GestureDetector(
|
||||
onTap: () => _onTapTab(tab),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 37.h,
|
||||
width: 54.w,
|
||||
// padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? const Color(0xFF3D7DF5)
|
||||
: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
tab.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15.spMin,
|
||||
color: isActive ? Colors.white : const Color(0xFF687FAD),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 空状态(对标 DataEmpty + exam_empty_bg)=====
|
||||
Widget _buildEmpty() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-06-10/exam_empty_bg1781078431804.png',
|
||||
),
|
||||
width: 200.w,
|
||||
fit: BoxFit.fitWidth,
|
||||
errorWidget: (_, _, _) => SizedBox(width: 200.w, height: 200.h),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'暂无内容',
|
||||
style: TextStyle(
|
||||
fontSize: 14.spMin,
|
||||
color: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 考试卡片 ====================
|
||||
|
||||
class _ExamCard extends StatelessWidget {
|
||||
const _ExamCard({required this.item, required this.onTap});
|
||||
|
||||
final ExamDto item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
static const String _cardBgBook =
|
||||
'/StaticResource/2026-09-10/book_icon_final1789026573675.png';
|
||||
|
||||
// 书图标设计尺寸(目标渲染尺寸)
|
||||
static const double _bookWidth = 124;
|
||||
static const double _bookHeight = 124;
|
||||
// 卡片尺寸
|
||||
static const double _cardHeight = 92;
|
||||
static const double _cardRadius = 24;
|
||||
// ⚠ book_icon.png 自带透明 padding,下面这些偏移用于补偿图片留白
|
||||
// 让书图案的左下角对齐卡片左下角。换图时需重新调整。
|
||||
static const double _bookOffsetLeft = -14; // 左补留白(宽度加大了,偏移也加大)
|
||||
static const double _bookOffsetBottom = 6; // 底补留白
|
||||
static const double _bookExtraHeight = 0; // 放大补偿,让图案达到目标高度
|
||||
|
||||
String get _timeRange {
|
||||
// 日期相同只展示 endTime 的时分,去除秒
|
||||
final startParts = item.startTime.split(' ');
|
||||
final startDate = startParts.isNotEmpty ? startParts[0] : '';
|
||||
final startHhMm = startParts.length >= 2
|
||||
? startParts[1].split(':').take(2).join(':')
|
||||
: '';
|
||||
final endParts = item.endTime.split(' ');
|
||||
final endHhMm = endParts.length >= 2
|
||||
? endParts[1].split(':').take(2).join(':')
|
||||
: '';
|
||||
return '$startDate $startHhMm - $endHhMm';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 学科图标:按 baseSubjectName 动态取,无匹配兜底数学
|
||||
final subjectIcon = subjectIconUrl(item.baseSubjectName);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
// ⚠ 书图/卡片尺寸统一用 .w:宽高同基准,锁定盒子宽高比,
|
||||
// 避免手机(.w按375稿/.h按812稿)与pad(设备尺寸)缩放系数不同导致
|
||||
// BoxFit.contain 缩放分支翻转、书图被压扁。
|
||||
height: (_bookHeight + _bookExtraHeight).w, // 整体占位 = 书图容器高度(溢出卡片)
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 底层:白色圆角卡片(垂直居中)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: (_bookHeight + _bookExtraHeight - _cardHeight).w / 2,
|
||||
child: Container(
|
||||
height: _cardHeight.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(_cardRadius.r),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0A1D2B47),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 中层:左侧书形装饰图
|
||||
// 图片自带透明 padding,用 _bookOffsetLeft/Bottom/ExtraHeight 补偿
|
||||
Positioned(
|
||||
left: _bookOffsetLeft.w,
|
||||
bottom: _bookOffsetBottom.w,
|
||||
width: _bookWidth.w,
|
||||
height: (_bookHeight + _bookExtraHeight).w,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(_cardBgBook),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.bottomLeft,
|
||||
errorWidget: (_, __, ___) =>
|
||||
SizedBox(width: _bookWidth.w, height: _bookHeight.w),
|
||||
),
|
||||
),
|
||||
// 学科小图标(叠在书图上半部分,书图 110 宽 / 139 高容器)
|
||||
Positioned(
|
||||
left: 22.w,
|
||||
top: 14.w,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: subjectIcon,
|
||||
width: 56.w,
|
||||
height: 56.w,
|
||||
errorWidget: (_, _, _) => Icon(
|
||||
Icons.menu_book,
|
||||
size: 56.w,
|
||||
color: const Color(0xFF3D7DF5),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 上层:内容
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧书图区域的占位宽(内容从书图右边开始)
|
||||
SizedBox(width: 90.w),
|
||||
// 中间 + 右侧
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _buildMid()),
|
||||
_buildRight(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 学科标签(覆盖在书图底部胶囊上)
|
||||
Positioned(
|
||||
left: -10.w,
|
||||
bottom: 26.w,
|
||||
width: _bookWidth.w,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 6.w,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE8F2FF),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Text(
|
||||
item.baseSubjectName,
|
||||
style: TextStyle(
|
||||
fontSize: 16.spMin,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF3D7DF5),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMid() {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.taskName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18.spMin,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
_timeRange,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.spMin,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRight() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
// 得分
|
||||
_gradientScoreText('${item.userScore}', 32.spMin, FontWeight.w700),
|
||||
Text(
|
||||
' / ${item.totalScore}',
|
||||
style: TextStyle(
|
||||
fontSize: 16.spMin,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16.w,
|
||||
color: const Color(0xFF3D7DF5),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 渐变得分文字:linear-gradient(270deg, #2DC9FF -2.95%, #2B73F9 73.86%)
|
||||
Widget _gradientScoreText(String text, double fontSize, FontWeight weight) {
|
||||
return ShaderMask(
|
||||
shaderCallback: (rect) => ui.Gradient.linear(
|
||||
rect.topRight,
|
||||
rect.topLeft,
|
||||
const [Color(0xFF2DC9FF), Color(0xFF2B73F9)],
|
||||
const [-0.0295, 0.7386],
|
||||
TileMode.clamp,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: weight,
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+101
-141
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/confirm_box.dart';
|
||||
import 'package:new_learn/core/components/image_preview_viewer.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/config/app_env.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
@@ -97,12 +96,20 @@ class MyPage extends ConsumerWidget {
|
||||
// 第一组:学习功能菜单
|
||||
_MenuCard(
|
||||
items: [
|
||||
_MenuItem(
|
||||
imageUrl:
|
||||
'${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png',
|
||||
title: '我的考试',
|
||||
onTap: () {
|
||||
context.push('/myExam');
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
imageUrl:
|
||||
'${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png',
|
||||
title: '我的报告',
|
||||
onTap: () {
|
||||
// TODO: 跳转到我的报告页
|
||||
context.push('/myReport');
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
@@ -110,7 +117,7 @@ class MyPage extends ConsumerWidget {
|
||||
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png',
|
||||
title: '我的笔记',
|
||||
onTap: () {
|
||||
// TODO: 跳转到我的笔记页
|
||||
context.push('/myNote');
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
@@ -118,7 +125,7 @@ class MyPage extends ConsumerWidget {
|
||||
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/error.png',
|
||||
title: '错题本',
|
||||
onTap: () {
|
||||
// TODO: 跳转到错题本页
|
||||
context.push('/myWrong');
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
@@ -126,7 +133,7 @@ class MyPage extends ConsumerWidget {
|
||||
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/collect.png',
|
||||
title: '我的收藏',
|
||||
onTap: () {
|
||||
// TODO: 跳转到我的收藏页
|
||||
context.push('/myCollect');
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
@@ -158,6 +165,18 @@ class MyPage extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
// 第三组:设置(Vue my.vue #ifdef APP-PLUS 专属,Flutter 始终显示)
|
||||
_MenuCard(
|
||||
items: [
|
||||
_MenuItem(
|
||||
imageUrl:
|
||||
'${AppEnv.imgBaseUrl}/StaticResource/2026-08-20/setting1787192171875.png',
|
||||
title: '设置',
|
||||
onTap: () => context.push('/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
),
|
||||
@@ -218,95 +237,19 @@ class _UserHeader extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _UserHeaderState extends ConsumerState<_UserHeader> {
|
||||
static const String _avatarHeroTag = 'my-page-avatar';
|
||||
|
||||
/// 刚选中的本地新头像(null = 还没选)
|
||||
PickedImage? _pickedAvatar;
|
||||
|
||||
/// 点击头像:
|
||||
/// 1. 没头像 → 直接更换;
|
||||
/// 2. 有头像 → 先弹动作表,支持"预览头像 / 更换头像"。
|
||||
/// 点击头像 → 直接触发选图上传(对齐 Vue my.vue changeAvatar 逻辑,无预览中间步骤)
|
||||
Future<void> _onAvatarTap() async {
|
||||
if (!_hasPreviewableAvatar) {
|
||||
await _pickAndUploadAvatar();
|
||||
return;
|
||||
}
|
||||
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (dialogContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.visibility_outlined),
|
||||
title: const Text('预览头像'),
|
||||
onTap: () => Navigator.of(dialogContext).pop('preview'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('更换头像'),
|
||||
onTap: () => Navigator.of(dialogContext).pop('change'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (action == 'preview') {
|
||||
await _previewAvatar();
|
||||
return;
|
||||
}
|
||||
if (action == 'change') {
|
||||
await _pickAndUploadAvatar();
|
||||
}
|
||||
await _pickAndUploadAvatar();
|
||||
}
|
||||
|
||||
bool get _hasPreviewableAvatar {
|
||||
return _pickedAvatar != null || widget.avatar.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<void> _previewAvatar() async {
|
||||
final picked = _pickedAvatar;
|
||||
if (picked?.bytes != null) {
|
||||
await ImagePreviewViewer.showSingle(
|
||||
context,
|
||||
imageProvider: MemoryImage(picked!.bytes!),
|
||||
heroTag: _avatarHeroTag,
|
||||
label: '头像预览',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.avatar.isNotEmpty) {
|
||||
await ImagePreviewViewer.showSingle(
|
||||
context,
|
||||
imageProvider: NetworkImage(widget.avatar),
|
||||
heroTag: _avatarHeroTag,
|
||||
label: '头像预览',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择头像:拍照/相册 → OSS 直传 → 调业务接口更新头像字段 → 更新全局用户
|
||||
///
|
||||
/// 这里和 UniApp 旧项目保持一致,完整链路是:
|
||||
/// 1. 先选图片;
|
||||
/// 2. 本地压缩/方向修正;
|
||||
/// 3. 上传到 OSS,拿到最终图片 url;
|
||||
/// 4. 调 /api/v1/User/UpdateUserAvatar,把服务器里的 avatar 字段改掉;
|
||||
/// 5. 更新 currentUserProvider + 本地缓存,让界面立刻刷新。
|
||||
///
|
||||
/// 为什么"我的"页这里不再重新请求用户信息接口:
|
||||
/// 登录成功时已经把完整用户信息拉下来放进了 currentUserProvider;
|
||||
/// 头像上传成功后,本次只有 avatar 这一个字段发生变化,
|
||||
/// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。
|
||||
/// 选择头像并上传(对齐 Vue my.vue 的 changeAvatar 完整链路):
|
||||
/// 1. showPickSheet 选图(相册+拍照,内部已含权限请求)
|
||||
/// 2. ImageProcessor 压缩 + EXIF 方向修正
|
||||
/// 3. 显示 loading 遮罩(对应 Vue uni.showLoading '上传中...')
|
||||
/// 4. OssUploader.uploadImage 直传 OSS
|
||||
/// 5. UserApi.updateUserAvatar 调业务接口更新头像字段
|
||||
/// 6. 弹审核确认框(对应 Vue proxy.$confirm)
|
||||
/// 注意:不上传后立刻刷新用户信息,头像保持旧的,等后端审核通过后自然生效
|
||||
Future<void> _pickAndUploadAvatar() async {
|
||||
final picked = await PickerService.instance.showPickSheet(
|
||||
context,
|
||||
@@ -315,19 +258,15 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
|
||||
final image = picked.first;
|
||||
// 先显示本地临时预览,让用户选完图后有即时反馈。
|
||||
setState(() => _pickedAvatar = image);
|
||||
|
||||
// ① 读图片字节:Web 走 bytes;原生走 photo_manager 纯 Dart 的 originBytes
|
||||
// (不 import dart:io,Web 才能编译)
|
||||
// ① 读图片字节(Web 走 bytes;原生走 originBytes)
|
||||
final bytes = image.bytes ?? await image.entity?.originBytes;
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
_toast('读取图片失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 压缩 + EXIF 方向修正(后台 isolate 跑,不卡页面)
|
||||
// 头像只需 512 长边;null = 尺寸超出 14~4000 像素硬限制
|
||||
// ② 压缩 + EXIF 方向修正
|
||||
final processed = await ImageProcessor.process(
|
||||
bytes: bytes,
|
||||
fileName: image.name,
|
||||
@@ -338,7 +277,10 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
||||
return;
|
||||
}
|
||||
|
||||
// ③ OSS 直传:申请路径 → 拿 STS → V4 签名 → PUT(拦截器自动带 Token)
|
||||
// ③ 显示 loading 遮罩(对应 Vue uni.showLoading)
|
||||
if (!mounted) return;
|
||||
_showLoading('上传中...');
|
||||
|
||||
try {
|
||||
final fileName = _replaceExtension(
|
||||
image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||
@@ -348,6 +290,8 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
||||
'头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B '
|
||||
'(${processed.contentType}, $fileName)',
|
||||
);
|
||||
|
||||
// ④ OSS 直传
|
||||
final url = await OssUploader.uploadImage(
|
||||
bytes: processed.bytes,
|
||||
fileName: fileName,
|
||||
@@ -355,30 +299,60 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
||||
scene: UploadScene.avatar,
|
||||
);
|
||||
|
||||
// ④ 通知业务接口"当前用户头像已改成这个 OSS 地址"。
|
||||
// 这一步对应 UniApp 的 UpdateUserAvatar({ avatar: url }),
|
||||
// 目的是把服务器里当前用户资料的 avatar 字段真正更新掉。
|
||||
// ⑤ 业务接口更新头像字段(对应 Vue UpdateUserAvatar({ avatar: url }))
|
||||
await UserApi.updateUserAvatar(url);
|
||||
|
||||
// ⑤ 头像字段只改本地当前用户信息即可,不需要"我的"页再重新请求接口。
|
||||
// updateAvatar(url) 内部会做两件事:
|
||||
// - 更新 currentUserProvider 的内存状态;
|
||||
// - 把更新后的用户对象写回 SharedPreferences。
|
||||
// 因此所有 ref.watch(currentUserProvider) 的页面都会一起刷新。
|
||||
await ref.read(currentUserProvider.notifier).updateAvatar(url);
|
||||
|
||||
// 上传成功后清掉本地临时预览,改为展示服务器返回的头像 URL。
|
||||
// 否则界面会一直优先显示 _pickedAvatar,看起来像"方向没修正",
|
||||
// 实际上你看到的是本地原图/相册缩略图,不是处理后的上传结果。
|
||||
if (mounted) {
|
||||
setState(() => _pickedAvatar = null);
|
||||
}
|
||||
|
||||
// ⑥ 关闭 loading,弹审核确认框(对应 Vue proxy.$confirm)
|
||||
if (!mounted) return;
|
||||
_toast('头像上传成功');
|
||||
_hideLoading();
|
||||
|
||||
await showConfirmBox(
|
||||
context,
|
||||
title: '上传成功,等待审核完成会自动生效',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_toast('头像上传失败:$e');
|
||||
_hideLoading();
|
||||
_toast('笔记上传失败,请检查网络后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示全局 loading 遮罩(对应 Vue uni.showLoading)
|
||||
void _showLoading(String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => PopScope(
|
||||
canPop: false,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(color: Colors.white, fontSize: 14.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _hideLoading() {
|
||||
if (Navigator.of(context, rootNavigator: true).canPop()) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,38 +363,24 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 头像主体:按「本地新图 → 网络图 → 默认首字圆」三级渲染
|
||||
/// 头像主体:网络头像 → 默认首字圆 二级渲染
|
||||
Widget _buildAvatar() {
|
||||
final size = 62.w;
|
||||
final picked = _pickedAvatar;
|
||||
|
||||
// 一级:刚选中的本地头像
|
||||
if (picked != null) {
|
||||
return Hero(
|
||||
tag: _avatarHeroTag,
|
||||
child: ClipOval(
|
||||
child: PickerService.buildThumb(picked, width: size, height: size),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 二级:服务器返回的网络头像(加载失败自动降级到默认头像)
|
||||
// 一级:服务器返回的网络头像(加载失败自动降级到默认头像)
|
||||
if (widget.avatar.isNotEmpty) {
|
||||
return Hero(
|
||||
tag: _avatarHeroTag,
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
widget.avatar,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _buildDefaultAvatar(),
|
||||
),
|
||||
return ClipOval(
|
||||
child: Image.network(
|
||||
widget.avatar,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _buildDefaultAvatar(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 三级:默认头像(首字圆)
|
||||
// 二级:默认头像(首字圆)
|
||||
return _buildDefaultAvatar();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/api/my_report/api.dart';
|
||||
import 'package:new_learn/core/api/my_report/model.dart';
|
||||
import 'package:new_learn/core/components/common_paged_list.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/subject_icons.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue
|
||||
///
|
||||
/// 一、数据来源:
|
||||
/// 报告分页 → UserReportApi.instance.getReportPage → CommonPagedList
|
||||
///
|
||||
/// 二、UI 结构:
|
||||
/// Scaffold 白色 AppBar + 背景 #F5F8FA 的列表区域
|
||||
/// 卡片背景图 + 左对齐 icon/标签 + 中间文字 + 右侧箭头
|
||||
///
|
||||
/// 三、点击卡片 → 跳 /report-detail(WebView 详情页)
|
||||
/// reportUrl 为空 → SnackBar "报告暂未生成"
|
||||
///
|
||||
/// 四、先只做 phone 布局,Pad 后续再说
|
||||
/// ============================================================
|
||||
|
||||
/// 取学科名(报告名前 2 个字,对标 Vue getSubjectName)
|
||||
String _extractSubjectName(UserReportDto item) {
|
||||
final name = item.reportName.trim();
|
||||
if (name.length < 2) return name;
|
||||
return name.substring(0, 2);
|
||||
}
|
||||
|
||||
/// 格式化创建时间(对标 Vue getReportTime)
|
||||
String _formatCreateTime(String raw) {
|
||||
if (raw.isEmpty) return '';
|
||||
final normalized = raw.replaceAll('T', ' ');
|
||||
return normalized.length > 16 ? normalized.substring(0, 16) : normalized;
|
||||
}
|
||||
|
||||
class MyReportPage extends ConsumerStatefulWidget {
|
||||
const MyReportPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MyReportPage> createState() => _MyReportPageState();
|
||||
}
|
||||
|
||||
class _MyReportPageState extends ConsumerState<MyReportPage> {
|
||||
static const int _pageSize = 10;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
talker.debug('打开页面:我的报告');
|
||||
}
|
||||
|
||||
/// 分页加载回调
|
||||
Future<PagingResult<UserReportDto>> _loadPage(int pageIndex) async {
|
||||
final page = await UserReportApi.instance.getReportPage(
|
||||
pageIndex: pageIndex,
|
||||
pageSize: _pageSize,
|
||||
);
|
||||
final hasNext = page.data.length >= _pageSize;
|
||||
return PagingResult(data: page.data, hasNext: hasNext);
|
||||
}
|
||||
|
||||
/// 点击卡片:跳 WebView 详情页
|
||||
void _onTapCard(UserReportDto item) {
|
||||
if (item.reportUrl.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('报告暂未生成')));
|
||||
return;
|
||||
}
|
||||
context.push(
|
||||
'/report-detail',
|
||||
extra: {
|
||||
'title': item.reportName.isEmpty ? '我的报告' : item.reportName,
|
||||
'url': item.reportUrl,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text('我的报告', style: TextStyle(color: Color(0xFF1D2B47))),
|
||||
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
|
||||
),
|
||||
),
|
||||
body: Container(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
child: CommonPagedList<UserReportDto>(
|
||||
load: _loadPage,
|
||||
padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 0),
|
||||
// separatorBuilder: (_, _) => SizedBox(height: 14.h),
|
||||
buildItem: (context, item, index) {
|
||||
return _ReportCard(item: item, onTap: () => _onTapCard(item));
|
||||
},
|
||||
emptyWidget: Transform.translate(
|
||||
offset: Offset(0, -80.h),
|
||||
child: const Center(
|
||||
child: Text('暂无内容', style: TextStyle(color: Color(0xFF687FAD))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【_ReportCard】报告卡片
|
||||
///
|
||||
/// 左侧装饰与我的考试(exam)卡片一致:
|
||||
/// 白色圆角卡片 + 左侧溢出书图(book_icon)+ 学科图标叠在书图上 +
|
||||
/// 底部学科名胶囊。书图/卡片尺寸统一用 .w 锁定宽高比,适配 pad。
|
||||
///
|
||||
/// 内容区:中间 [报告名 + 时间],右侧 [箭头]。
|
||||
/// ============================================================
|
||||
class _ReportCard extends StatelessWidget {
|
||||
const _ReportCard({required this.item, required this.onTap});
|
||||
|
||||
final UserReportDto item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
static const String _cardBgBook =
|
||||
'/StaticResource/2026-09-10/book_icon_final1789026573675.png';
|
||||
|
||||
// 书图标 / 卡片尺寸(与 exam 卡片一致,统一 .w 锁比例)
|
||||
static const double _bookWidth = 124;
|
||||
static const double _bookHeight =
|
||||
124; // 卡片间距 = _bookHeight - _cardHeight(书图向上探出的高度)
|
||||
static const double _cardHeight = 92;
|
||||
static const double _cardRadius = 24;
|
||||
// ⚠ book_icon.png 自带透明 padding,偏移用于补偿留白
|
||||
static const double _bookOffsetLeft = -14;
|
||||
static const double _bookOffsetBottom = -10;
|
||||
static const double _bookExtraHeight = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subjectName = _extractSubjectName(item);
|
||||
final iconUrl = subjectIconUrl(subjectName);
|
||||
final timeText = _formatCreateTime(item.createTime);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: (_bookHeight + _bookExtraHeight).w,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 底层:白色圆角卡片(底部对齐,书图只向上溢出)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
height: _cardHeight.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(_cardRadius.r),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0A1D2B47),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 中层:左侧书形装饰图
|
||||
Positioned(
|
||||
left: _bookOffsetLeft.w,
|
||||
bottom: _bookOffsetBottom.w,
|
||||
width: _bookWidth.w,
|
||||
height: (_bookHeight + _bookExtraHeight).w,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(_cardBgBook),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.bottomLeft,
|
||||
errorWidget: (_, __, ___) =>
|
||||
SizedBox(width: _bookWidth.w, height: _bookHeight.w),
|
||||
),
|
||||
),
|
||||
// 学科小图标(叠在书图上半部分)
|
||||
Positioned(
|
||||
left: 22.w,
|
||||
top: 14.w,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: iconUrl,
|
||||
width: 56.w,
|
||||
height: 56.w,
|
||||
errorWidget: (_, _, _) => Icon(
|
||||
Icons.menu_book,
|
||||
size: 56.w,
|
||||
color: const Color(0xFF3D7DF5),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 上层:内容(与卡片同高,底部对齐)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: _cardHeight.w,
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧书图区域占位
|
||||
SizedBox(width: 90.w),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _buildMid(timeText)),
|
||||
_buildRight(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 学科标签(覆盖在书图底部胶囊上,卡片贴底)
|
||||
Positioned(
|
||||
left: -10.w,
|
||||
bottom: 10.w,
|
||||
width: _bookWidth.w,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 6.w,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE8F2FF),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Text(
|
||||
subjectName,
|
||||
style: TextStyle(
|
||||
fontSize: 16.spMin,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF3D7DF5),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 中间:报告名 + 时间
|
||||
Widget _buildMid(String timeText) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.reportName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18.spMin,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
timeText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.spMin,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 右侧:箭头
|
||||
Widget _buildRight() {
|
||||
return Icon(
|
||||
Icons.chevron_right,
|
||||
color: const Color(0xFF4B6B99),
|
||||
size: 22.sp,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ReportDetailPage】报告详情 WebView 页 —— 对标 Vue reportWebView.vue
|
||||
///
|
||||
/// 一、为什么用 flutter_inappwebview 而不是官方 webview_flutter:
|
||||
/// ✅ 需要 JS → Flutter 回调(addJavaScriptHandler)
|
||||
/// ✅ 需要 Flutter → JS 主动调用(callAsyncJavaScript / evaluateJavascript)
|
||||
/// ✅ 比 webview_flutter 功能更全,后续扩展方便
|
||||
///
|
||||
/// 二、路由参数(通过 GoRouteState.extra 传 Map):
|
||||
/// context.push('/report-detail', extra: {'title': '...', 'url': '...'})
|
||||
/// - title:AppBar 标题
|
||||
/// - url:WebView 加载的 H5 报告 URL
|
||||
///
|
||||
/// 三、JS Bridge 完整指南(H5 ↔ Flutter 双向通信):
|
||||
///
|
||||
/// ┌─────────────── H5 调 Flutter ───────────────┐
|
||||
/// │ │
|
||||
/// │ Flutter 注册 handler(见 _registerJsHandlers)│
|
||||
/// │ controller.addJavaScriptHandler( │
|
||||
/// │ handlerName: 'closePage', │
|
||||
/// │ callback: (args) { ... } │
|
||||
/// │ ); │
|
||||
/// │ │
|
||||
/// │ H5 侧调用: │
|
||||
/// │ // 关闭当前 WebView 页 │
|
||||
/// │ window.flutter_inappwebview │
|
||||
/// │ .callHandler('closePage'); │
|
||||
/// │ │
|
||||
/// │ // 带参数调用: │
|
||||
/// │ window.flutter_inappwebview │
|
||||
/// │ .callHandler('onReportAction', { │
|
||||
/// │ action: 'download', │
|
||||
/// │ data: 'xxx' │
|
||||
/// │ }); │
|
||||
/// │ │
|
||||
/// │ H5 兼容写法(推荐,同时支持 UniApp web-view): │
|
||||
/// │ function callFlutter(handlerName, args) { │
|
||||
/// │ // Flutter inappwebview │
|
||||
/// │ if (window.flutter_inappwebview) { │
|
||||
/// │ window.flutter_inappwebview │
|
||||
/// │ .callHandler(handlerName, args); │
|
||||
/// │ } │
|
||||
/// │ // UniApp web-view(可选,按需扩展) │
|
||||
/// │ // #ifdef APP-PLUS │
|
||||
/// │ // plus.webview.currentWebview()... │
|
||||
/// │ // #endif │
|
||||
/// │ } │
|
||||
/// │ callFlutter('closePage'); │
|
||||
/// │ │
|
||||
/// └───────────────────────────────────────────────┘
|
||||
///
|
||||
/// ┌─────────────── Flutter 调 H5 ───────────────┐
|
||||
/// │ │
|
||||
/// │ // 主动调 H5 全局函数: │
|
||||
/// │ await controller.callAsyncJavaScript( │
|
||||
/// │ functionBody: │
|
||||
/// │ 'window.onFlutterMessage("hello");', │
|
||||
/// │ ); │
|
||||
/// │ │
|
||||
/// │ // 或者执行任意 JS 代码: │
|
||||
/// │ await controller.evaluateJavascript( │
|
||||
/// │ source: 'document.title', │
|
||||
/// │ ); │
|
||||
/// │ │
|
||||
/// └───────────────────────────────────────────────┘
|
||||
///
|
||||
/// 四、支持的 JS handler 列表(H5 可调用这些名字):
|
||||
///
|
||||
/// handlerName 参数 作用
|
||||
/// ──────────────────────────────────────────────────
|
||||
/// closePage 无 关闭当前 WebView 页
|
||||
/// onReportAction {action,data} H5 主动通知 Flutter 做某事
|
||||
/// action 可取:
|
||||
/// 'share' → 触发分享(TODO: 接 share_plus)
|
||||
/// 'download' → 下载/保存报告
|
||||
/// 'refresh' → Flutter 刷列表后回退
|
||||
///
|
||||
/// 五、平台配置(如需):
|
||||
/// Android:AndroidManifest.xml 需 INTERNET 权限(flutter 默认已有)
|
||||
/// iOS:Info.plist 需 NSAppTransportSecurity 允许 HTTP(如果报告页是 HTTPS 可忽略)
|
||||
/// ============================================================
|
||||
|
||||
class ReportDetailPage extends StatefulWidget {
|
||||
const ReportDetailPage({super.key, required this.title, required this.url});
|
||||
|
||||
final String title;
|
||||
final String url;
|
||||
|
||||
@override
|
||||
State<ReportDetailPage> createState() => _ReportDetailPageState();
|
||||
}
|
||||
|
||||
class _ReportDetailPageState extends State<ReportDetailPage> {
|
||||
InAppWebViewController? _webController;
|
||||
|
||||
/// WebView 加载进度(0~1),用于底部蓝色进度条
|
||||
double _loadingProgress = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
talker.debug('打开报告详情:${widget.title} → ${widget.url}');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(color: Color(0xFF1D2B47)),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
// 先尝试 WebView 内部 history 回退,不行再 pop 页面
|
||||
onPressed: () async {
|
||||
final controller = _webController;
|
||||
if (controller != null && await controller.canGoBack()) {
|
||||
await controller.goBack();
|
||||
} else if (context.mounted) {
|
||||
context.canPop() ? context.pop() : context.go('/my');
|
||||
}
|
||||
},
|
||||
),
|
||||
// 底部加载进度条(可选,提升体验)
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(2),
|
||||
child: _loadingProgress < 1.0
|
||||
? LinearProgressIndicator(
|
||||
value: _loadingProgress,
|
||||
backgroundColor: Colors.transparent,
|
||||
valueColor: const AlwaysStoppedAnimation(Color(0xFF3D7DF5)),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
body: InAppWebView(
|
||||
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
// 允许 JS(报告页大概率需要)
|
||||
javaScriptEnabled: true,
|
||||
// 支持内联视频播放
|
||||
allowsInlineMediaPlayback: true,
|
||||
// 允许文件访问
|
||||
allowFileAccess: true,
|
||||
// 自适应内容(防止移动端 H5 在 WebView 里显示过宽)
|
||||
useHybridComposition: true,
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
_webController = controller;
|
||||
// ✅【关键】注册 JS Bridge:H5 通过 window.flutter_inappwebview.callHandler 调这些
|
||||
_registerJsHandlers(controller);
|
||||
talker.debug('WebView created, JS handlers registered');
|
||||
},
|
||||
onLoadStart: (controller, url) {
|
||||
setState(() => _loadingProgress = 0);
|
||||
talker.debug('WebView loading start: $url');
|
||||
},
|
||||
onLoadStop: (controller, url) async {
|
||||
setState(() => _loadingProgress = 1);
|
||||
talker.debug('WebView loading stop: $url');
|
||||
// TODO: 可选:页面加载完后主动调 H5 的初始化函数
|
||||
// await controller.callAsyncJavaScript(
|
||||
// functionBody: 'window.onFlutterReady?.();',
|
||||
// );
|
||||
},
|
||||
onProgressChanged: (controller, progress) {
|
||||
setState(() => _loadingProgress = progress / 100.0);
|
||||
},
|
||||
// WebView 内部请求拦截(可选:如需给 H5 接口加 token,可在这里处理)
|
||||
// shouldInterceptRequest: (controller, request) async {
|
||||
// final url = request.url?.toString() ?? '';
|
||||
// if (url.contains('/api/')) {
|
||||
// // 给 H5 的接口请求追加 Authorization header
|
||||
// return request.copyWith(
|
||||
// headers: {
|
||||
// ...request.headers ?? {},
|
||||
// 'Authorization': 'Bearer ${DioClient.instance.accessToken}',
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// return null;
|
||||
// },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// JS Bridge 注册 —— 所有 handler 在这里统一定义
|
||||
//
|
||||
// H5 调用方式:window.flutter_inappwebview.callHandler('closePage')
|
||||
// 或带参数:window.flutter_inappwebview.callHandler('onReportAction', {action:'xxx'})
|
||||
//
|
||||
// 注意:handler 必须在 onWebViewCreated 里注册,页面加载前就要就绪。
|
||||
// ============================================================
|
||||
void _registerJsHandlers(InAppWebViewController controller) {
|
||||
// ---- 1. closePage:关闭当前 WebView 页 ----
|
||||
// H5 调用:window.flutter_inappwebview.callHandler('closePage')
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'closePage',
|
||||
callback: (args) {
|
||||
talker.debug('[JS→Flutter] closePage called, args: $args');
|
||||
if (context.mounted) {
|
||||
context.canPop() ? context.pop() : context.go('/my');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 2. onReportAction:H5 通知 Flutter 执行某动作 ----
|
||||
// H5 调用:
|
||||
// window.flutter_inappwebview.callHandler('onReportAction', {
|
||||
// action: 'download',
|
||||
// data: 'report_xxx.pdf'
|
||||
// })
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'onReportAction',
|
||||
callback: (args) {
|
||||
// args 是 H5 传过来的参数数组,第一个元素通常是对象
|
||||
final payload = args.isNotEmpty ? args.first : null;
|
||||
talker.debug('[JS→Flutter] onReportAction called, payload: $payload');
|
||||
|
||||
// 解析 action 字段
|
||||
String? action;
|
||||
String? data;
|
||||
if (payload is Map) {
|
||||
action = payload['action']?.toString();
|
||||
data = payload['data']?.toString();
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'share':
|
||||
// TODO: 接 share_plus 分享报告链接
|
||||
talker.debug('[JS→Flutter] → share, data: $data');
|
||||
break;
|
||||
case 'download':
|
||||
// TODO: 下载/保存报告文件
|
||||
talker.debug('[JS→Flutter] → download, data: $data');
|
||||
break;
|
||||
case 'refresh':
|
||||
// H5 处理完后要求 Flutter 刷列表并回退
|
||||
talker.debug('[JS→Flutter] → refresh');
|
||||
if (context.mounted) {
|
||||
context.canPop() ? context.pop(true) : context.go('/myReport');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
talker.debug('[JS→Flutter] → unknown action: $action');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 3. 更多 handler 按需添加 ----
|
||||
// 模板(复制下面这几行,改 handlerName 和 callback 即可):
|
||||
//
|
||||
// controller.addJavaScriptHandler(
|
||||
// handlerName: 'yourHandlerName',
|
||||
// callback: (args) {
|
||||
// // TODO: 你的处理逻辑
|
||||
// },
|
||||
// );
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_webController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
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/user_collect/model.dart';
|
||||
import 'package:new_learn/core/api/user_note/api.dart';
|
||||
import 'package:new_learn/core/api/user_note/model.dart';
|
||||
import 'package:new_learn/core/components/common_paged_list.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/core/components/subject_tag.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【NotePage】我的笔记 —— 对标 Vue pages/myPackage/note/note.vue
|
||||
///
|
||||
/// 页面结构(对齐 Vue,从上到下):
|
||||
/// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载)
|
||||
/// 2. #F5F8FA 列表区:CommonPagedList 游标分页
|
||||
/// 卡片 = SubjectTag + 时间 + title + 内容富文本(限高渐隐)
|
||||
/// + 右上角笔记类型角标
|
||||
///
|
||||
/// 与收藏/错题列表的差异:
|
||||
/// ① 没有顶部总数头;
|
||||
/// ② 列表不按类型过滤,type 由每条数据自带:
|
||||
/// 1=题目笔记(note-stem.png)2=讲义笔记(note-teaching.png),
|
||||
/// 仅用角标图标区分,详情页再按 type 渲染不同内容;
|
||||
/// ③ 标题字段 title、正文字段 content。
|
||||
///
|
||||
/// 分页:pageKey 当 pageIndex;prevPageId 闭包维护——首页/刷新重置为 0,
|
||||
/// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表回首页。
|
||||
/// ============================================================
|
||||
class NotePage extends StatefulWidget {
|
||||
const NotePage({super.key});
|
||||
|
||||
@override
|
||||
State<NotePage> createState() => _NotePageState();
|
||||
}
|
||||
|
||||
class _NotePageState extends State<NotePage> {
|
||||
static const int _pageSize = 10;
|
||||
|
||||
/// 学科 Tab 列表("全部"为客户端插入,id=null)
|
||||
List<CollectTab> _tabs = const [CollectTab(name: '全部')];
|
||||
|
||||
/// 当前选中的学科 id(null = 全部)
|
||||
int? _subjectId;
|
||||
|
||||
/// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0
|
||||
int _prevPageId = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
talker.debug('打开页面:我的笔记');
|
||||
_loadTabs();
|
||||
}
|
||||
|
||||
/// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑)
|
||||
Future<void> _loadTabs() async {
|
||||
try {
|
||||
final tabs = await UserNoteApi.instance.getSubjects();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tabs = [const CollectTab(name: '全部'), ...tabs];
|
||||
});
|
||||
} catch (e) {
|
||||
talker.error('获取笔记学科Tab失败', e);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tabs = const [CollectTab(name: '全部')];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页加载(CommonPagedList 回调)
|
||||
Future<PagingResult<UserNoteItem>> _load(int pageKey) async {
|
||||
// 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0)
|
||||
if (pageKey == 1) _prevPageId = 0;
|
||||
|
||||
final res = await UserNoteApi.instance.getNoteList(
|
||||
subjectId: _subjectId,
|
||||
pageIndex: pageKey,
|
||||
pageSize: _pageSize,
|
||||
prevPageId: _prevPageId,
|
||||
);
|
||||
|
||||
// 更新游标为本页最后一条 id
|
||||
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
|
||||
|
||||
return PagingResult<UserNoteItem>(
|
||||
data: res.data,
|
||||
hasNext: res.total > pageKey * _pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
/// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页
|
||||
void _onTabTap(CollectTab tab) {
|
||||
if (_subjectId == tab.id) return;
|
||||
setState(() {
|
||||
_subjectId = tab.id;
|
||||
_prevPageId = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// 点击卡片 → 笔记详情页
|
||||
/// TODO: 详情页待接入,需要按 item.type 区分:
|
||||
/// type=1 题目笔记 / type=2 讲义笔记,详情页内容结构不同;
|
||||
/// Vue 跳转:/pages/myPackage/noteDetail/index?id=${item.id}&type=${item.type}
|
||||
void _goDetail(UserNoteItem item) {
|
||||
talker.debug('笔记详情待接入:id=${item.id} type=${item.type}(1题目笔记/2讲义笔记)');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: const Text('我的笔记', style: TextStyle(color: Color(0xFF1D2B47))),
|
||||
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
// 列表区(#F5F8FA 灰底)
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
child: CommonPagedList<UserNoteItem>(
|
||||
// 切学科时重建列表,状态自动回第一页
|
||||
key: ValueKey('note_list_$_subjectId'),
|
||||
// Vue list-refresh-wrap padding-top:32rpx + listWrapper 28rpx
|
||||
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
|
||||
emptyWidget: _buildEmpty(),
|
||||
load: _load,
|
||||
buildItem: (context, item, index) =>
|
||||
_NoteCard(item: item, onTap: () => _goDetail(item)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 学科筛选胶囊 Tab 栏(横向滚动)
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx)
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
itemCount: _tabs.length,
|
||||
separatorBuilder: (_, _) => SizedBox(width: 12.w),
|
||||
itemBuilder: (context, i) {
|
||||
final tab = _tabs[i];
|
||||
final active = _subjectId == tab.id;
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onTap: () => _onTabTap(tab),
|
||||
child: Container(
|
||||
height: 32.h,
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? const Color(0xFF3D7DF5)
|
||||
: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
tab.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: active ? Colors.white : const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 空状态:空态图 + 文案(对齐 Vue DataEmpty noNotess.png)
|
||||
Widget _buildEmpty() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build('/StaticResource/2026-04-11/noNotess.png'),
|
||||
width: 160.w,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, _) => const SizedBox.shrink(),
|
||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'暂无笔记',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条笔记卡片:SubjectTag + 时间 + title + 内容富文本 + 右上角类型角标
|
||||
class _NoteCard extends StatelessWidget {
|
||||
const _NoteCard({required this.item, required this.onTap});
|
||||
|
||||
final UserNoteItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 14.h,
|
||||
), // Vue listItem margin-bottom: 28rpx
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none, // 允许类型角标超出卡片顶部
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
// 去掉点击时默认的灰色水波纹/高亮(Vue 端卡片无按压反馈)
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
// Vue padding: t-32 l/r-28 b-28
|
||||
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 第一行:学科标签 + 时间
|
||||
Row(
|
||||
children: [
|
||||
SubjectTag(subject: item.subjectName),
|
||||
SizedBox(width: 12.w),
|
||||
Text(
|
||||
item.createTime,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp, // Vue font-24
|
||||
color: const Color(0xFFB4C2E0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx
|
||||
// 笔记标题
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp, // Vue font-32
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 16.h,
|
||||
), // Vue question-stem-wrap margin-top: 32rpx
|
||||
// 笔记内容富文本(限高渐隐)
|
||||
_NoteContentBox(html: item.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 右上角类型角标:Vue note-type-icon top:-4rpx right:28rpx 56rpx
|
||||
Positioned(top: -2.h, right: 14.w, child: _buildTypeIcon()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 笔记类型角标:type=1 题目笔记(note-stem)/ 2 讲义笔记(note-teaching)
|
||||
Widget _buildTypeIcon() {
|
||||
final icon = item.type == 2
|
||||
? '/StaticResource/2026-04-07/note-teaching.png'
|
||||
: '/StaticResource/2026-04-07/note-stem.png';
|
||||
return CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(icon),
|
||||
width: 28.w,
|
||||
height: 28.h,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, _) => SizedBox(width: 28.w, height: 28.h),
|
||||
errorWidget: (_, _, _) => SizedBox(width: 28.w, height: 28.h),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 笔记内容容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx),
|
||||
/// 超出部分底部渐隐遮罩(与收藏/错题列表题干样式一致)
|
||||
class _NoteContentBox extends StatelessWidget {
|
||||
const _NoteContentBox({required this.html});
|
||||
|
||||
final String html;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(12.w), // Vue padding: 24rpx
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFBFD),
|
||||
borderRadius: BorderRadius.circular(8.r), // Vue 16rpx
|
||||
border: Border.all(color: const Color(0xFFE3ECFF)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉
|
||||
ClipRect(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 70.h),
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minHeight: 0,
|
||||
maxHeight: double.infinity,
|
||||
child: RichTextContent(
|
||||
html: html,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF495A7A),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部渐隐遮罩(Vue mask-layer 60rpx):
|
||||
// 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 30.h,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
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/user_collect/api.dart';
|
||||
import 'package:new_learn/core/api/user_collect/model.dart';
|
||||
import 'package:new_learn/core/components/common_paged_list.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/core/components/subject_tag.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/screen_adapt_template.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【QuestionListPage】我的收藏 / 错题本 列表页
|
||||
/// —— 对标 Vue pages/myPackage/myCollect/myCollect.vue
|
||||
/// 和 pages/myPackage/myWrong/myWrong.vue
|
||||
///
|
||||
/// 两个页面结构完全一致(同一套接口 UserCollect),仅 [type] 不同:
|
||||
/// type=1 我的收藏(/myCollect) type=2 错题本(/myWrong)
|
||||
/// 标题 / 总数文案 / 顶部背景图 / 空状态 / 装饰色带 由 [_config] 按 type 切换。
|
||||
///
|
||||
/// 页面结构(对齐 Vue,从上到下):
|
||||
/// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载)
|
||||
/// 2. 总数头:主题背景图 + 大号总数 + 装饰色带 + 文案(道错题/收藏题目)
|
||||
/// 3. #F5F8FA 列表区:CommonPagedList 游标分页
|
||||
/// 卡片 = SubjectTag + 时间 + packName + 题干富文本(限高渐隐)
|
||||
///
|
||||
/// 分页要点(游标分页,对齐 Vue useList):
|
||||
/// pageKey 当 pageIndex;prevPageId 在闭包里维护——首页/刷新重置为 0,
|
||||
/// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表(天然回首页)。
|
||||
///
|
||||
/// 题干限高:Vue 用 DOM 测量后加渐隐遮罩;Flutter 用 OverflowBox 限高 70.h
|
||||
/// 裁剪 + 底部固定渐隐遮罩(遮罩终点色与题干底色色值一致,内容不足时不可见)。
|
||||
/// ============================================================
|
||||
class QuestionListPage extends StatefulWidget {
|
||||
const QuestionListPage({super.key, required this.type});
|
||||
|
||||
/// 1=我的收藏 2=错题本
|
||||
final int type;
|
||||
|
||||
@override
|
||||
State<QuestionListPage> createState() => _QuestionListPageState();
|
||||
}
|
||||
|
||||
class _QuestionListPageState extends State<QuestionListPage> {
|
||||
static const int _pageSize = 10;
|
||||
|
||||
/// 学科 Tab 列表("全部"为客户端插入,id=null)
|
||||
List<CollectTab> _tabs = const [CollectTab(name: '全部')];
|
||||
|
||||
/// 当前选中的学科 id(null = 全部)
|
||||
int? _subjectId;
|
||||
|
||||
/// 顶部总数(来自分页响应 total)
|
||||
int _total = 0;
|
||||
|
||||
/// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0
|
||||
int _prevPageId = 0;
|
||||
|
||||
_PageConfig get _config =>
|
||||
widget.type == 1 ? _PageConfig.collect : _PageConfig.wrong;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
talker.debug('打开页面:${widget.type == 1 ? '我的收藏' : '错题本'}');
|
||||
_loadTabs();
|
||||
}
|
||||
|
||||
/// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑)
|
||||
Future<void> _loadTabs() async {
|
||||
try {
|
||||
final tabs = await UserCollectApi.instance.getSubjects(widget.type);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tabs = [const CollectTab(name: '全部'), ...tabs];
|
||||
});
|
||||
} catch (e) {
|
||||
talker.error('获取学科Tab失败', e);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_tabs = const [CollectTab(name: '全部')];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页加载(CommonPagedList 回调):只写请求 + 返回数据
|
||||
Future<PagingResult<UserCollectItem>> _load(int pageKey) async {
|
||||
// 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0)
|
||||
if (pageKey == 1) _prevPageId = 0;
|
||||
|
||||
final res = await UserCollectApi.instance.getCollectList(
|
||||
type: widget.type,
|
||||
subjectId: _subjectId,
|
||||
pageIndex: pageKey,
|
||||
pageSize: _pageSize,
|
||||
prevPageId: _prevPageId,
|
||||
);
|
||||
|
||||
// 更新游标为本页最后一条 id
|
||||
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _total = res.total);
|
||||
}
|
||||
|
||||
return PagingResult<UserCollectItem>(
|
||||
data: res.data,
|
||||
hasNext: res.total > pageKey * _pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
/// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页
|
||||
void _onTabTap(CollectTab tab) {
|
||||
if (_subjectId == tab.id) return;
|
||||
setState(() {
|
||||
_subjectId = tab.id;
|
||||
_prevPageId = 0;
|
||||
_total = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// 点击卡片 → 题目解析详情页
|
||||
/// TODO: 详情页待接入——错题本跳 questionAnalysis、收藏跳 collectAnalysis,
|
||||
/// 参数:questionId=${item.questionId}
|
||||
/// recordQuestionId=${item.recordQuestionId}
|
||||
/// listDetailId=${item.id}
|
||||
void _goDetail(UserCollectItem item) {
|
||||
talker.debug(
|
||||
'题目详情待接入:type=${widget.type} questionId=${item.questionId} '
|
||||
'recordQuestionId=${item.recordQuestionId} listDetailId=${item.id}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cfg = _config;
|
||||
final isPad =
|
||||
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
cfg.title,
|
||||
style: const TextStyle(color: Color(0xFF1D2B47)),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 1. 学科筛选 Tab(白底)
|
||||
_buildTabBar(),
|
||||
// 2. 总数头(背景图 + 大号数字)
|
||||
_buildTotalHeader(cfg, isPad),
|
||||
// 3. 列表区(#F5F8FA 灰底)
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
child: CommonPagedList<UserCollectItem>(
|
||||
// 切学科时重建列表,状态自动回第一页
|
||||
key: ValueKey('question_list_${widget.type}_$_subjectId'),
|
||||
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
|
||||
emptyWidget: _buildEmpty(cfg),
|
||||
load: _load,
|
||||
buildItem: (context, item, index) => _QuestionCard(
|
||||
item: item,
|
||||
onTap: () => _goDetail(item),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 学科筛选胶囊 Tab 栏(横向滚动)
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx)
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
itemCount: _tabs.length,
|
||||
separatorBuilder: (_, __) => SizedBox(width: 12.w),
|
||||
itemBuilder: (context, i) {
|
||||
final tab = _tabs[i];
|
||||
final active = _subjectId == tab.id;
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onTap: () => _onTabTap(tab),
|
||||
child: Container(
|
||||
height: 32.h,
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? const Color(0xFF3D7DF5)
|
||||
: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
tab.name,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: active
|
||||
? Colors.white
|
||||
: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 总数头:背景图(widthFix)+ 大号总数 + 装饰色带 + 文案
|
||||
Widget _buildTotalHeader(_PageConfig cfg, bool isPad) {
|
||||
final numFontSize = isPad ? 52.5.sp : 35.sp;
|
||||
final labelFontSize = isPad ? 21.sp : 14.sp;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 背景图:宽度撑满,高度按图比例(对齐 Vue mode="widthFix")
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(isPad ? cfg.topBgPad : cfg.topBgPhone),
|
||||
width: double.infinity,
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topCenter,
|
||||
placeholder: (_, __) => const SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
// 数字 + 色带 + 文案(绝对定位叠在图上)
|
||||
Positioned(
|
||||
top: isPad ? 45.h : 16.5.h, // Vue padding-top: 90rpx / 33rpx
|
||||
left: 30.w, // Vue padding-left: 60rpx
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 大号数字 + 底部装饰色带
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: isPad ? -7.h : -4.h,
|
||||
child: Container(
|
||||
height: 9.h, // Vue 18rpx
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(50.r),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: cfg.underlineColors,
|
||||
stops: const [0, 0.36, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_total',
|
||||
style: TextStyle(
|
||||
fontSize: numFontSize,
|
||||
fontWeight: isPad ? FontWeight.w500 : FontWeight.w600,
|
||||
color: const Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: 10.w), // Vue margin-l-20
|
||||
// 文案(Vue margin-t-30:相对数字略微下沉)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 15.h),
|
||||
child: Text(
|
||||
cfg.totalLabel,
|
||||
style: TextStyle(
|
||||
fontSize: labelFontSize,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 空状态:空态图 + 文案(对齐 Vue DataEmpty)
|
||||
Widget _buildEmpty(_PageConfig cfg) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(cfg.emptyImg),
|
||||
width: 160.w,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, __) => const SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
cfg.emptyText,
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条收藏/错题卡片:SubjectTag + 时间 + packName + 题干富文本
|
||||
///
|
||||
/// 按压反馈:按下缩放到 0.97、抬起回弹,背景始终白色
|
||||
/// (不用 InkWell 灰底,改用 GestureDetector 驱动 AnimatedScale)。
|
||||
class _QuestionCard extends StatefulWidget {
|
||||
const _QuestionCard({required this.item, required this.onTap});
|
||||
|
||||
final UserCollectItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_QuestionCard> createState() => _QuestionCardState();
|
||||
}
|
||||
|
||||
class _QuestionCardState extends State<_QuestionCard> {
|
||||
/// 是否处于按压状态(控制 0.97 缩放)
|
||||
bool _pressed = false;
|
||||
|
||||
void _setPressed(bool v) {
|
||||
if (_pressed != v) setState(() => _pressed = v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 14.h), // Vue listItem margin-bottom: 28rpx
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) => _setPressed(true),
|
||||
onTapUp: (_) => _setPressed(false),
|
||||
onTapCancel: () => _setPressed(false),
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedScale(
|
||||
scale: _pressed ? 0.97 : 1.0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.easeOut,
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
child: Padding(
|
||||
// Vue padding: t-32 l/r-28 b-28
|
||||
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 第一行:学科标签 + 时间
|
||||
Row(
|
||||
children: [
|
||||
SubjectTag(subject: item.subjectName),
|
||||
const Spacer(),
|
||||
Text(
|
||||
item.createTime,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp, // Vue font-24
|
||||
color: const Color(0xFFB4C2E0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx
|
||||
// 试卷/礼包名
|
||||
Text(
|
||||
item.packName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp, // Vue font-32
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx
|
||||
// 题干富文本(限高渐隐)
|
||||
_StemBox(html: item.questionStem),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 题干容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx),
|
||||
/// 超出部分底部渐隐遮罩
|
||||
class _StemBox extends StatelessWidget {
|
||||
const _StemBox({required this.html});
|
||||
|
||||
final String html;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(12.w), // Vue padding: 24rpx
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFBFD),
|
||||
borderRadius: BorderRadius.circular(8.r), // Vue 16rpx
|
||||
border: Border.all(color: const Color(0xFFE3ECFF)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉
|
||||
ClipRect(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 70.h),
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minHeight: 0,
|
||||
maxHeight: double.infinity,
|
||||
child: RichTextContent(
|
||||
html: html,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF495A7A),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部渐隐遮罩(Vue mask-layer 60rpx):
|
||||
// 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 30.h,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面差异化配置(按 type 切换文案/图片/配色)
|
||||
class _PageConfig {
|
||||
const _PageConfig({
|
||||
required this.title,
|
||||
required this.totalLabel,
|
||||
required this.topBgPhone,
|
||||
required this.topBgPad,
|
||||
required this.emptyImg,
|
||||
required this.emptyText,
|
||||
required this.underlineColors,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String totalLabel;
|
||||
final String topBgPhone;
|
||||
final String topBgPad;
|
||||
final String emptyImg;
|
||||
final String emptyText;
|
||||
final List<Color> underlineColors;
|
||||
|
||||
/// 我的收藏(type=1):橙色系装饰
|
||||
static final _PageConfig collect = _PageConfig(
|
||||
title: '我的收藏',
|
||||
totalLabel: '收藏题目',
|
||||
topBgPhone: '/StaticResource/2026-04-08/my-collect-top-bg.png',
|
||||
topBgPad: '/StaticResource/2026-06-02/my-collect-top-bg-pad1780378706432.png',
|
||||
emptyImg: '/StaticResource/2026-04-11/contentEmptys.png',
|
||||
emptyText: '暂无收藏',
|
||||
// Vue: linear-gradient(90deg, #F59C3D00 0%, #FFCA1D33 36%, #FDAD8A80 100%)
|
||||
underlineColors: const [
|
||||
Color(0x00F59C3D),
|
||||
Color(0x33FFCA1D),
|
||||
Color(0x80FDAD8A),
|
||||
],
|
||||
);
|
||||
|
||||
/// 错题本(type=2):蓝色系装饰
|
||||
static final _PageConfig wrong = _PageConfig(
|
||||
title: '错题本',
|
||||
totalLabel: '道错题',
|
||||
topBgPhone: '/StaticResource/2026-04-08/my-wrong-top-bg.png',
|
||||
topBgPad: '/StaticResource/2026-06-02/my-wrong-top-bg-pad1780372865693.png',
|
||||
emptyImg: '/StaticResource/2026-04-11/noDatas.png',
|
||||
emptyText: '暂无错题',
|
||||
// Vue: linear-gradient(90deg, #3D7DF500 0%, #1D9CFF33 36%, #8AECFD80 100%)
|
||||
underlineColors: const [
|
||||
Color(0x003D7DF5),
|
||||
Color(0x331D9CFF),
|
||||
Color(0x808AECFD),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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/version/app_version_service.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AboutAppPage】关于APP页 —— 对齐 Vue pages/myPackage/aboutApp/index.vue
|
||||
///
|
||||
/// 菜单:隐私政策 / 用户协议 / 版本号(只读)
|
||||
/// 底部:APP备案号
|
||||
/// ============================================================
|
||||
class AboutAppPage extends ConsumerStatefulWidget {
|
||||
const AboutAppPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AboutAppPage> createState() => _AboutAppPageState();
|
||||
}
|
||||
|
||||
class _AboutAppPageState extends ConsumerState<AboutAppPage> {
|
||||
String _version = '';
|
||||
static const _recordNumber = '京ICP备2026019420号-2A';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadVersion();
|
||||
}
|
||||
|
||||
Future<void> _loadVersion() async {
|
||||
final service = AppVersionService.instance;
|
||||
if (service.versionName.isEmpty) {
|
||||
await service.init();
|
||||
}
|
||||
if (mounted) setState(() => _version = service.versionName);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F8FA),
|
||||
appBar: AppBar(
|
||||
title: const Text('关于APP'),
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
children: [
|
||||
// 隐私政策
|
||||
_buildRow(
|
||||
title: '隐私政策',
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 22,
|
||||
color: Color(0xFF7FB0F2),
|
||||
),
|
||||
onTap: () => context.push('/privacy-policy'),
|
||||
showBorder: true,
|
||||
),
|
||||
// 用户协议
|
||||
_buildRow(
|
||||
title: '用户协议',
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 22,
|
||||
color: Color(0xFF7FB0F2),
|
||||
),
|
||||
onTap: () => context.push('/user-agreement'),
|
||||
showBorder: true,
|
||||
),
|
||||
// 版本号(只读,无跳转)
|
||||
_buildRow(
|
||||
title: '版本号',
|
||||
trailing: Text(
|
||||
_version,
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF687FAD)),
|
||||
),
|
||||
onTap: null,
|
||||
showBorder: false,
|
||||
),
|
||||
SizedBox(height: 48.h),
|
||||
// 备案号
|
||||
Center(
|
||||
child: Text(
|
||||
'APP备案编号:$_recordNumber',
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF687FAD)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow({
|
||||
required String title,
|
||||
required Widget trailing,
|
||||
required VoidCallback? onTap,
|
||||
required bool showBorder,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 52.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showBorder)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Container(height: 1, color: const Color(0xFFEDF3F7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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/auth/api.dart';
|
||||
import 'package:new_learn/core/components/app_update_dialog.dart';
|
||||
import 'package:new_learn/core/components/confirm_box.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/providers/user_provider.dart';
|
||||
import 'package:new_learn/core/storage/user_storage.dart';
|
||||
import 'package:new_learn/core/version/app_version_service.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【SettingsPage】设置页 —— 对齐 Vue pages/myPackage/settings/index.vue
|
||||
///
|
||||
/// 三栏菜单:
|
||||
/// 检查更新(右侧显示当前版本号,手动触发版本比对弹窗)
|
||||
/// 关于APP (TODO: 跳转关于页)
|
||||
/// 注销账号 (Vue 端受 isAuditMode 控制,Flutter 暂先显示)
|
||||
///
|
||||
/// 样式:白色圆角卡片 + 分隔线,和 my_page 的 _MenuCard 风格一致
|
||||
/// ============================================================
|
||||
class SettingsPage extends ConsumerStatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
String _version = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadVersion();
|
||||
}
|
||||
|
||||
Future<void> _loadVersion() async {
|
||||
final service = AppVersionService.instance;
|
||||
if (service.versionName.isEmpty) {
|
||||
await service.init();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _version = service.versionName);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleCheckUpdate() async {
|
||||
// 手动检查更新:复用 AppVersionService + AppUpdateDialog
|
||||
try {
|
||||
final service = AppVersionService.instance;
|
||||
await service.init();
|
||||
final update = await service.checkForUpdate();
|
||||
if (!mounted) return;
|
||||
if (update == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('已是最新版本')));
|
||||
return;
|
||||
}
|
||||
await AppUpdateDialog.show(
|
||||
context,
|
||||
update,
|
||||
isForce: service.isForceUpdate(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('检查更新失败:$e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleCloseAccount() async {
|
||||
final ok = await showConfirmBox(
|
||||
context,
|
||||
title: '注销提醒',
|
||||
content: '账号注销后,将会删除账号的全部数据,不可恢复。请确认是否注销账号?',
|
||||
confirmText: '确定注销',
|
||||
cancelText: '我再想想',
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await UserApi.closeAccount();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('注销失败:$e')));
|
||||
return;
|
||||
}
|
||||
// 清理本地登录态 → 路由守卫自动踢回登录页
|
||||
DioClient.instance.accessToken = null;
|
||||
await UserStorage.instance.clearAll();
|
||||
if (!mounted) return;
|
||||
ref.read(currentUserProvider.notifier).set(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F8FA),
|
||||
appBar: AppBar(
|
||||
title: const Text('设置'),
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
children: [
|
||||
// 检查更新
|
||||
_buildRow(
|
||||
title: '检查更新',
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_version.isNotEmpty)
|
||||
Text(
|
||||
'v$_version',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 22,
|
||||
color: Color(0xFF7FB0F2),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: _handleCheckUpdate,
|
||||
),
|
||||
_buildDivider(),
|
||||
// 关于APP
|
||||
_buildRow(
|
||||
title: '关于APP',
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 22,
|
||||
color: Color(0xFF7FB0F2),
|
||||
),
|
||||
onTap: () => context.push('/about-app'),
|
||||
),
|
||||
_buildDivider(),
|
||||
// 注销账号
|
||||
_buildRow(
|
||||
title: '注销账号',
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 22,
|
||||
color: Color(0xFF7FB0F2),
|
||||
),
|
||||
onTap: _handleCloseAccount,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Container(height: 1, color: const Color(0xFFEDF3F7)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow({
|
||||
required String title,
|
||||
required Widget trailing,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 52.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF1D2B47)),
|
||||
),
|
||||
const Spacer(),
|
||||
trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
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/lexicon/api.dart';
|
||||
import 'package:new_learn/core/api/lexicon/model.dart';
|
||||
import 'package:new_learn/core/components/center_toast.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/screen_adapt_template.dart';
|
||||
import 'package:new_learn/features/recite/widgets/recite_circle_progress.dart';
|
||||
import 'package:new_learn/features/recite/widgets/recite_progress.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ChaptersPage】词汇章节 —— 对标 Vue pages/recitePackage/lexicon/lexicon.vue
|
||||
///
|
||||
/// 入参 bookId(路由 path 参数,对标 Vue onLoad options.bookId,默认 1)。
|
||||
/// 数据:getChapters(bookId),失败 toast「页面加载失败,请重试」。
|
||||
/// 点章节 → /recite/levels(extra 传整个 Chapter,替代 eventChannel);
|
||||
/// 从关卡链路返回后重新拉取(对标 Vue onShow 刷新)。
|
||||
/// ============================================================
|
||||
class ChaptersPage extends StatefulWidget {
|
||||
const ChaptersPage({super.key, required this.bookId});
|
||||
|
||||
final int bookId;
|
||||
|
||||
@override
|
||||
State<ChaptersPage> createState() => _ChaptersPageState();
|
||||
}
|
||||
|
||||
class _ChaptersPageState extends State<ChaptersPage> {
|
||||
final List<Chapter> _list = [];
|
||||
double _rate = 0;
|
||||
bool _loading = false;
|
||||
|
||||
bool get _isPad =>
|
||||
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_getList();
|
||||
}
|
||||
|
||||
Future<void> _getList() async {
|
||||
if (mounted) setState(() => _loading = true);
|
||||
try {
|
||||
final res = await LexiconApi.instance.getChapters(widget.bookId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_rate = res.totalFinishProgress * 100;
|
||||
_list
|
||||
..clear()
|
||||
..addAll(res.chapters);
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
CenterToast.show(
|
||||
context,
|
||||
'页面加载失败,请重试',
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onClickCell(Chapter item) async {
|
||||
await context.push('/recite/levels', extra: item);
|
||||
if (mounted) _getList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// 顶部背景大图
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/img_index_bgtop.png',
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
placeholder: (_, _) => const SizedBox.shrink(),
|
||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
// 白色内容卡(mp margin-top 19%,pad 30%)
|
||||
Positioned.fill(
|
||||
top: 0,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final topGap =
|
||||
constraints.maxHeight * (_isPad ? 0.30 : 0.19);
|
||||
return Padding(
|
||||
// Vue: -8rpx 让卡片向两侧溢出
|
||||
padding: EdgeInsets.only(top: topGap),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(_isPad ? 30.r : 24.r),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_buildContent(),
|
||||
// 鳄鱼装饰(top:-35rpx left:30rpx;pad 加大)
|
||||
Positioned(
|
||||
top: -17.5.h,
|
||||
left: _isPad ? 30.w : 15.w,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/img_index_crocodile.png',
|
||||
),
|
||||
width: _isPad ? 160.w : 109.w,
|
||||
fit: BoxFit.fitWidth,
|
||||
placeholder: (_, _) =>
|
||||
const SizedBox.shrink(),
|
||||
errorWidget: (_, _, _) =>
|
||||
const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
// 总进度圆环(top:-35rpx right:50rpx)
|
||||
Positioned(
|
||||
top: -17.5.h,
|
||||
right: _isPad ? 50.w : 25.w,
|
||||
child: _CircleTotalProgress(rate: _rate),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return Padding(
|
||||
// Vue chapter-list: 40rpx 横向,50rpx 顶
|
||||
padding: EdgeInsets.fromLTRB(20.w, 25.h, 20.w, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTitle(),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16.w,
|
||||
height: 16.w,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xFF1BC26E),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'努力加载中...',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.only(top: 20.h),
|
||||
itemCount: _list.length,
|
||||
itemBuilder: (_, i) =>
|
||||
_ChapterItem(item: _list[i], onTap: _onClickCell),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 词汇章节标题 + 短渐变线
|
||||
Widget _buildTitle() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'词汇章节',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF333333),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Container(
|
||||
width: 62.w,
|
||||
height: 4.h,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(2.r),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0x661BC26E), Color(0x667ED8AA)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 总进度圆环(白色衬底圆 + 绿色环 + 总进度文案)
|
||||
class _CircleTotalProgress extends StatelessWidget {
|
||||
const _CircleTotalProgress({required this.rate});
|
||||
|
||||
final double rate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 67.5.w,
|
||||
height: 67.5.w,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 白圆衬底(Vue 135rpx,top/left -8rpx)
|
||||
Container(
|
||||
width: 67.5.w,
|
||||
height: 67.5.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ReciteCircleProgress(
|
||||
percent: rate,
|
||||
size: 60,
|
||||
strokeWidth: 4,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${rate.toInt()}',
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF2E3238),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 1.h),
|
||||
child: Text(
|
||||
'%',
|
||||
style: TextStyle(
|
||||
fontSize: 8.sp,
|
||||
color: const Color(0xFF2E3238),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
'总进度',
|
||||
style: TextStyle(fontSize: 10.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节列表项(左封面叠字 + 右侧渐变信息卡)
|
||||
class _ChapterItem extends StatelessWidget {
|
||||
const _ChapterItem({required this.item, required this.onTap});
|
||||
|
||||
final Chapter item;
|
||||
final ValueChanged<Chapter> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percent = (item.finishProgress * 100).toInt();
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onTap(item),
|
||||
child: Container(
|
||||
height: 100.h,
|
||||
margin: EdgeInsets.symmetric(vertical: 10.h),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 渐变信息卡(左留 96 给封面;高度 90 贴底)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 90.h,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(96.w, 7.5.h, 14.w, 10.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xFFE1FAED), Colors.white],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'已完成',
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 3.w),
|
||||
Text(
|
||||
'$percent%',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF1BC26E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${item.finishLexiconCount}',
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
color: const Color(0xFF1BC26E),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'/${item.lexiconCount}',
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
color: const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
SegmentedProgressBar(progress: item.finishProgress * 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 左侧封面(height 100,叠白色 “index title”)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(item.cover),
|
||||
fit: BoxFit.fitHeight,
|
||||
placeholder: (_, _) => SizedBox(width: 96.w),
|
||||
errorWidget: (_, _, _) => SizedBox(width: 96.w),
|
||||
imageBuilder: (_, provider) => Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.35),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0, 0.45],
|
||||
),
|
||||
),
|
||||
child: Image(image: provider, fit: BoxFit.fitHeight),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 12.5.w,
|
||||
top: 9.h,
|
||||
right: 4.w,
|
||||
child: Text(
|
||||
item.index.isEmpty ? item.title : '${item.index} ${item.title}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 11.sp, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user