feat(conquerPackage, upload): 新增课前课中页面与多图上传组件,修复路由配置

- 实现攻克包模块的课前课中学习页面,包含学习计时、笔记上传、分页切换等完整逻辑
- 新增通用多图上传组件,支持编辑/查看两种业务模式
- 配置新页面的路由规则,并更新捕获列表页面的跳转逻辑为正式的go_router跳转
- 修复捕获列表页面跳转路径不匹配的问题
This commit is contained in:
chenshuainan
2026-09-11 09:37:37 +08:00
parent ee14373c6d
commit fe060ac974
11 changed files with 2230 additions and 20 deletions
@@ -0,0 +1,80 @@
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}
///
/// 注意:
/// - 页面只调这里,不直接碰 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();
}
}
@@ -0,0 +1,191 @@
/// ============================================================
/// 【课前课中模块模型】讲义列表 / 材料 / 笔记
///
/// 对标 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}}';
}
}
/// 讲义列表响应(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) {
+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) {
+8
View File
@@ -14,6 +14,7 @@ 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/conquerPackage/captureList/index.dart';
import 'package:new_learn/features/conquerPackage/beforeDuringClass/index.dart';
import 'package:new_learn/features/my/my_point/my_point_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';
@@ -169,6 +170,13 @@ final GoRouter appRouter = GoRouter(
builder: (_, state) =>
CaptureListPage(courseId: state.extra as String),
),
// ---------- 攻克包 - 课前课中 ----------
GoRoute(
path: '/conquerPackage/beforeDuringClass',
builder: (_, state) => BeforeDuringClassPage(
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,870 @@
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: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(
// 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() {
// TODO: 跳转到 transition 页,等路由配置好后替换
Navigator.of(context).pop();
}
// ===================== 笔记变化 =====================
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 =====================
class CaptureListPage extends StatelessWidget {
const CaptureListPage({super.key});
@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),
);
// TODO(路由): 替换为项目统一的路由跳转方式
// debugPrint('navigateTo: $path $params');
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
// );
context.push(path, extra: params);
}
/// 点击报告(跳转报告页)