3 Commits
Author SHA1 Message Date
chenshuainan 847986f861 feat: 新增过渡页面路由、上传组件及完善跳转逻辑
- 为conquerPackage模块添加过渡页面路由并实现页面跳转逻辑
- 新增通用多图上传组件Upload,支持多种模式与图片操作功能
- 新增任务相关的API及Model基础文件
- 注释课前课中页面的测试试题内容
2026-09-14 09:09:25 +08:00
chenshuainan fe86b6d824 Merge branch 'master' into chenshuainan 2026-09-11 09:38:12 +08:00
chenshuainan fe060ac974 feat(conquerPackage, upload): 新增课前课中页面与多图上传组件,修复路由配置
- 实现攻克包模块的课前课中学习页面,包含学习计时、笔记上传、分页切换等完整逻辑
- 新增通用多图上传组件,支持编辑/查看两种业务模式
- 配置新页面的路由规则,并更新捕获列表页面的跳转逻辑为正式的go_router跳转
- 修复捕获列表页面跳转路径不匹配的问题
2026-09-11 09:37:37 +08:00
15 changed files with 3081 additions and 22 deletions
@@ -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;
}
+80
View File
@@ -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;
}
}
+40
View File
@@ -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,
};
}
+7 -2
View File
@@ -84,10 +84,10 @@ class AudioPlayerCard extends StatefulWidget {
final List<double> playbackSpeeds;
@override
State<AudioPlayerCard> createState() => _AudioPlayerCardState();
AudioPlayerCardState createState() => AudioPlayerCardState();
}
class _AudioPlayerCardState extends State<AudioPlayerCard>
class AudioPlayerCardState extends State<AudioPlayerCard>
with WidgetsBindingObserver {
/// 底层播放器(just_audio)
final AudioPlayer _player = AudioPlayer();
@@ -123,6 +123,11 @@ class _AudioPlayerCardState extends State<AudioPlayerCard>
super.dispose();
}
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停音频)
void pause() {
_player.pause();
}
/// App 切后台 / 来电 → 自动暂停
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
+38 -2
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:new_learn/core/components/audio_player_card.dart';
import 'package:new_learn/core/components/video_player_view.dart';
@@ -79,13 +81,47 @@ class RichTextContent extends StatelessWidget {
textStyle: textStyle,
onTapUrl: onTapUrl ?? _defaultOnTapUrl,
customStylesBuilder: customStylesBuilder,
customWidgetBuilder: _combinedCustomWidget,
customWidgetBuilder: _combinedCustomWidget(context),
renderMode: renderMode,
);
}
/// 合并默认替换 + 用户自定义替换
CustomWidgetBuilder get _combinedCustomWidget => (element) {
CustomWidgetBuilder _combinedCustomWidget(BuildContext context) => (element) {
// 内置:<img src="*.svg"> → flutter_svg 行内渲染
// core 版 flutter_widget_from_html 不支持 SVG,手动处理
if (element.localName == 'img') {
final src = element.attributes['src'];
if (src != null && src.toLowerCase().endsWith('.svg')) {
final width = double.tryParse(element.attributes['width'] ?? '');
final height = double.tryParse(element.attributes['height'] ?? '');
final w = width != null ? width.w : null;
final h = height != null ? height.h : null;
// 超采样(SSAA):SVG 以 3 倍尺寸光栅化再缩回来,消除真机边缘锯齿
const scale = 3.0;
return InlineCustomWidget(
child: SizedBox(
width: w,
height: h,
child: OverflowBox(
maxWidth: w != null ? w * scale : double.infinity,
maxHeight: h != null ? h * scale : double.infinity,
child: Transform.scale(
scale: 1 / scale,
alignment: Alignment.center,
child: SvgPicture.network(
src,
width: w != null ? w * scale : null,
height: h != null ? h * scale : null,
fit: BoxFit.contain,
clipBehavior: Clip.antiAlias,
),
),
),
),
);
}
}
// 内置:<audio> → 本项目 AudioPlayerCard
if (element.localName == 'audio') {
final src = element.attributes['src'];
+674
View File
@@ -0,0 +1,674 @@
import 'dart:typed_data';
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/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 }
/// 图片项(统一承载:本地已�?+ 已上传)
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<({PickedImage origin, ProcessedImage processed})> validList =
[];
for (final img in picked) {
final bytes = img.bytes ?? await img.entity?.originBytes;
if (bytes == null || bytes.isEmpty) continue;
final processed = await ImageProcessor.process(
bytes: bytes,
fileName: img.name,
maxDimension: 1920,
);
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;
}
// TODO: AIGrading 类型的旋转预览弹窗待实现(Vue 端有 $showImagePreview�?
// 暂时直接上传,后续有需求再�?
await _startUpload(validList);
} 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: [
Container(
padding: EdgeInsets.only(right: 14.w),
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
),
alignment: Alignment.bottomRight,
repeat: ImageRepeat.noRepeat,
),
),
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)),
),
);
}
}
+7 -2
View File
@@ -107,10 +107,10 @@ class VideoPlayerView extends StatefulWidget {
final ChewieProgressColors? materialProgressColors;
@override
State<VideoPlayerView> createState() => _VideoPlayerViewState();
VideoPlayerViewState createState() => VideoPlayerViewState();
}
class _VideoPlayerViewState extends State<VideoPlayerView>
class VideoPlayerViewState extends State<VideoPlayerView>
with WidgetsBindingObserver {
/// 官方底层控制器
VideoPlayerController? _videoController;
@@ -144,6 +144,11 @@ class _VideoPlayerViewState extends State<VideoPlayerView>
super.dispose();
}
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停视频)
void pause() {
_videoController?.pause();
}
/// App 切后台 / 来电 → 自动暂停,防止声音继续播
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
+15
View File
@@ -15,6 +15,8 @@ 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/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';
@@ -202,6 +204,19 @@ final GoRouter appRouter = GoRouter(
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: '/register', builder: (_, _) => const RegisterPage()),
// app_router.dart 的 routes 里加,不用管跳转逻辑
@@ -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,
);
}
}
@@ -1,18 +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;
class CaptureListPage extends StatelessWidget {
const CaptureListPage({super.key});
@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 Scaffold(
appBar: AppBar(title: const Text('Align 对齐布局 Demo11111')),
// ---------- 下方即为核心代码,可直接复制到 Scaffold body ----------
body: Container(
width: 300.w,
height: 300.h,
color: Colors.grey[300],
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,
),
),
),
),
),
],
);
}
}
@@ -1,5 +1,6 @@
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';
@@ -220,7 +221,7 @@ class _CaptureListPageState extends State<CaptureListPage> {
// 跳转分支(对标 Vue navigateToCaptureInfo)
if (res.hasHandout) {
// 有讲义 → 课前课中页
_navigateTo('/conquer/beforeDuringClass', {
_navigateTo('/conquerPackage/beforeDuringClass', {
'status': '1',
'packId': item.id,
'courseId': widget.courseId,
@@ -261,13 +262,14 @@ class _CaptureListPageState extends State<CaptureListPage> {
}
}
/// 页面跳转(占位 —— 待路由统一后替换为正式路由名)
/// 页面跳转(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),
);
// debugPrint('navigateTo: $path $params');
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
// );
context.push(path, extra: params);
}
/// 点击报告(跳转报告页)
@@ -0,0 +1,703 @@
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';
/// ============================================================
/// 【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> {
// ========== 颜色常量 ==========
static const Color _bgColor = Color(0xFFF5F8FA);
static const Color _titleColor = Color(0xFF1D2B47);
static const Color _subtitleColor = Color(0xFF99A4BA);
static const Color _primary = Color(0xFF3D7DF5);
static const Color _labelBg = Color(0xFFE3ECFF);
// ========== 路由参数 ==========
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) {
// TODO(路由): classExercises 页建好后替换
// context.push('/conquerPackage/classExercises', extra: {
// 'courseId': _courseId,
// 'packId': _packId,
// 'recordId': _recordId,
// });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('习题页待开发'),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
// ===================== 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(),
),
],
),
),
// 课前引导蒙层
if (_status == TransitionStatus.beforeClass && _showMask)
_SlideMask(
visible: _showMask,
onClose: () => setState(() => _showMask = false),
),
],
),
);
}
// ===================== NavBar =====================
Widget _buildNavBar() {
return SizedBox(
height: 44.h,
child: Stack(
children: [
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(),
),
),
],
),
);
}
// ===================== Body =====================
Widget _buildBody() {
switch (_status) {
case TransitionStatus.beforeClass:
return _buildBeforeClass();
case TransitionStatus.duringClass:
return _buildDuringClass();
case TransitionStatus.afterClass:
return _buildAfterClass();
}
}
// ---------- 课前讲义 ----------
Widget _buildBeforeClass() {
return Column(
children: [
// 背景插图
CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/before-class-bac.png',
),
width: 170.w,
height: 149.h,
fit: BoxFit.contain,
placeholder: (_, __) => SizedBox.shrink(),
errorWidget: (_, __, ___) => SizedBox(
width: 170.w,
height: 149.h,
child: const Icon(Icons.menu_book_outlined,
size: 80, color: _primary),
),
),
SizedBox(height: 50.h),
// 标签
_buildLabel('课前讲义'),
SizedBox(height: 12.h),
// 标题
_buildTitle(
name: _overcomeInfo.name,
subtitle: '知识点讲义学习',
),
const Spacer(),
// 开始学习按钮
_buildStartButton('开始学习'),
SizedBox(height: 46.h),
],
);
}
// ---------- 课中视频 ----------
Widget _buildDuringClass() {
return Column(
children: [
// 背景插图
CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/during-class-bac.png',
),
width: 186.w,
height: 120.h,
fit: BoxFit.contain,
placeholder: (_, __) => SizedBox.shrink(),
errorWidget: (_, __, ___) => SizedBox(
width: 186.w,
height: 120.h,
child:
const Icon(Icons.play_circle_outline, size: 80, color: _primary),
),
),
SizedBox(height: 42.h),
_buildLabel('课中视频'),
SizedBox(height: 12.h),
_buildTitle(
name: _overcomeInfo.name,
subtitle: '知识点视频学习',
),
const Spacer(),
_buildStartButton('开始学习'),
SizedBox(height: 46.h),
],
);
}
// ---------- 课后练习 ----------
Widget _buildAfterClass() {
return SingleChildScrollView(
child: Column(
children: [
// 背景插图
CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/after-class-bac.png',
),
width: 170.w,
height: 170.h,
fit: BoxFit.contain,
placeholder: (_, __) => SizedBox.shrink(),
errorWidget: (_, __, ___) => SizedBox(
width: 170.w,
height: 170.h,
child:
const Icon(Icons.assignment_outlined, size: 80, color: _primary),
),
),
SizedBox(height: 25.h),
_buildLabel('课后练习'),
SizedBox(height: 12.h),
if (_overcomeInfo.name.isNotEmpty)
_buildTitle(
name: _overcomeInfo.name,
subtitle: '知识点题目攻克',
padding: EdgeInsets.symmetric(horizontal: 5.w),
),
SizedBox(height: 28.h),
// 提示卡片
_buildAfterTipsCard(),
SizedBox(height: 28.h),
],
),
);
}
// ===================== 公共组件 =====================
Widget _buildLabel(String text) {
return Container(
width: 60.w,
height: 20.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _labelBg,
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
text,
style: TextStyle(
fontSize: 12.sp,
color: _primary,
),
),
);
}
Widget _buildTitle({
required String name,
required String subtitle,
EdgeInsetsGeometry? padding,
}) {
return Padding(
padding: padding ?? EdgeInsets.zero,
child: Column(
children: [
Text(
name.isNotEmpty ? '《$name》' : '',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: _titleColor,
height: 1.4,
),
),
SizedBox(height: 4.h),
Text(
subtitle,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: _titleColor,
height: 1.4,
),
),
],
),
);
}
Widget _buildStartButton(String text) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 14.w),
child: SizedBox(
width: double.infinity,
height: 47.h,
child: FilledButton(
onPressed: _startLearning,
style: FilledButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.r),
),
textStyle: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
),
child: Text(text),
),
),
);
}
// ===================== 课后提示卡片 =====================
Widget _buildAfterTipsCard() {
return Center(
child: Container(
margin: EdgeInsets.symmetric(horizontal: 14.w),
constraints: BoxConstraints(maxWidth: 347.w),
width: double.infinity,
child: LayoutBuilder(
builder: (context, constraints) {
// 卡片实际宽度(平板上被 maxWidth 限制,手机上 = 屏宽 - 左右边距)
final cardW = constraints.maxWidth;
// 设计稿卡片宽度 = 750rpx - 28rpx*2 = 694rpx = 347dp
// 内部所有水平/垂直定位都按卡片宽度比例换算,
// 保证背景图(fitWidth)和内部定位永远同步
double wRatio(double designPx) => cardW * designPx / 347.0;
return Container(
decoration: BoxDecoration(
color: const Color(0xFFF2F7FF),
borderRadius: BorderRadius.circular(12.r),
image: DecorationImage(
image: NetworkImage(
ImgUrl.build(
'/StaticResource/2026-04-01/after-class-tips.png',
),
),
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
),
),
padding: EdgeInsets.fromLTRB(
wRatio(14),
wRatio(129), // 背景图随宽度缩放,padding-top 也按卡片宽比例
wRatio(14),
14.h,
),
child: Stack(
clipBehavior: Clip.none,
children: [
// 攻克标准百分比(按卡片宽度比例定位,与背景图缩放同步)
Positioned(
right: wRatio(75.5),
top: -wRatio(64),
child: Text(
'${_overcomeInfo.overcomeStandard}%',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: _primary,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTipItem('包含单选、多选、判断、填空全题型'),
SizedBox(height: 4.h),
_buildTipItem('每题展示深度解析与解题逻辑'),
SizedBox(height: 15.h),
// 正式进入攻克按钮
Opacity(
opacity: _overcomeInfo.hasQuestion ? 1.0 : 0.6,
child: SizedBox(
width: double.infinity,
height: 47.h,
child: FilledButton(
onPressed: _overcomeInfo.hasQuestion
? _startLearning
: null,
style: FilledButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
disabledBackgroundColor: _primary,
disabledForegroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.r),
),
textStyle: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
),
child: const Text('正式进入攻克'),
),
),
),
],
),
],
),
);
},
),
),
);
}
Widget _buildTipItem(String text) {
return Padding(
padding: EdgeInsets.only(left: 24.w, bottom: 4.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 4.w,
height: 4.w,
margin: EdgeInsets.only(right: 13.w),
decoration: const BoxDecoration(
color: _primary,
shape: BoxShape.circle,
),
),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 14.sp,
color: _subtitleColor,
height: 1.5,
),
),
),
],
),
);
}
}
/// ============================================================
/// 【_SlideMask】滑动蒙层引导组件
/// 对标 Vue pages/conquerPackage/transitionPage/slide-mask.vue
///
/// 功能:
/// - 半透明黑色蒙层背景
/// - 显示引导图
/// - 向左滑动超过阈值 → 整个蒙层滑出屏幕并触发 onClose
/// - 滑动不足阈值 → 回弹
/// ============================================================
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 _slideOutController;
late Animation<double> _slideOutAnim;
@override
void initState() {
super.initState();
_slideOutController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_slideOutAnim = Tween<double>(begin: 0, end: 0).animate(
CurvedAnimation(parent: _slideOutController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_slideOutController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant _SlideMask oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible && !oldWidget.visible) {
_offsetX = 0;
_isAnimatingOut = false;
_slideOutController.reset();
}
}
void _handleTouchStart(DragStartDetails details) {
if (!widget.visible || _isAnimatingOut) return;
_startX = details.globalPosition.dx;
_isSliding = true;
}
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) {
// 超过阈值 → 滑出动画
_startSlideOut();
} else {
// 回弹
setState(() => _offsetX = 0);
}
}
void _startSlideOut() {
_isAnimatingOut = true;
final screenWidth = MediaQuery.of(context).size.width;
_slideOutAnim = Tween<double>(begin: _offsetX, end: -screenWidth).animate(
CurvedAnimation(parent: _slideOutController, curve: Curves.easeInOut),
);
_slideOutController.forward().then((_) {
widget.onClose();
});
}
@override
Widget build(BuildContext context) {
if (!widget.visible) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _slideOutAnim,
builder: (context, child) {
final dx = _isAnimatingOut ? _slideOutAnim.value : _offsetX;
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: [
// 引导图
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,
),
),
),
),
),
],
),
),
),
),
);
},
);
}
}
+40
View File
@@ -675,6 +675,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -1143,6 +1151,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -1748,6 +1764,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
vector_math:
dependency: transitive
description:
+1
View File
@@ -119,6 +119,7 @@ dependencies:
# 替换(customWidgetBuilder),video 留扩展点,图片/表格原生支持。
# 版本:0.17.x 要求 Flutter 3.32+ / just_audio ^0.10.4(本项目满足)
flutter_widget_from_html_core: ^0.17.2
flutter_svg: ^2.0.17
# 图片预览:社区成熟方案,支持双指缩放/拖拽/多图左右切换
# 单图用 PhotoView,多图用 PhotoViewGallery;