feat(conquerPackage, upload): 新增课前课中页面与多图上传组件,修复路由配置
- 实现攻克包模块的课前课中学习页面,包含学习计时、笔记上传、分页切换等完整逻辑 - 新增通用多图上传组件,支持编辑/查看两种业务模式 - 配置新页面的路由规则,并更新捕获列表页面的跳转逻辑为正式的go_router跳转 - 修复捕获列表页面跳转路径不匹配的问题
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【笔记相关接口】讲义笔记 / 试题笔记
|
||||
///
|
||||
/// 对应 Vue 端 api/task/index.ts:
|
||||
/// HandoutNoteAdd → POST /api/v1/HandoutNote/Add
|
||||
/// HandoutNoteDel → DELETE /api/v1/HandoutNote/Del/{id}
|
||||
/// QuestionNoteAdd → POST /api/v1/QuestionNote/Add
|
||||
/// QuestionNoteDel → DELETE /api/v1/QuestionNote/Del/{id}
|
||||
///
|
||||
/// 注意:页面只调这里,不直接碰 Dio;异常不在这里 catch,交页面统一处理。
|
||||
/// ============================================================
|
||||
class NoteApi {
|
||||
NoteApi._();
|
||||
|
||||
static final NoteApi instance = NoteApi._();
|
||||
|
||||
/// 添加讲义笔记 → POST /api/v1/HandoutNote/Add
|
||||
///
|
||||
/// [id] 讲义 id
|
||||
/// [url] 原图 URL(OSS 直传后返回)
|
||||
/// [thumbnailUrl] 缩略图 URL
|
||||
Future<NoteImage> handoutNoteAdd({
|
||||
required dynamic id,
|
||||
required String url,
|
||||
required String thumbnailUrl,
|
||||
}) async {
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/HandoutNote/Add',
|
||||
data: {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
},
|
||||
);
|
||||
// 后端统一 {code, message, data} 包装:data 存在取 data,否则用顶层
|
||||
final payload =
|
||||
(data is Map<String, dynamic> && data.containsKey('data'))
|
||||
? (data['data'] as Map<String, dynamic>?) ?? <String, dynamic>{}
|
||||
: data as Map<String, dynamic>;
|
||||
return NoteImage.fromJson(payload);
|
||||
}
|
||||
|
||||
/// 删除讲义笔记 → DELETE /api/v1/HandoutNote/Del/{id}
|
||||
///
|
||||
/// 返回 true 表示删除成功
|
||||
Future<bool> handoutNoteDel(dynamic id) async {
|
||||
await DioClient.instance.delete('/api/v1/HandoutNote/Del/$id');
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 添加试题笔记 → POST /api/v1/QuestionNote/Add
|
||||
Future<NoteImage> questionNoteAdd({
|
||||
required dynamic id,
|
||||
required String url,
|
||||
required String thumbnailUrl,
|
||||
}) async {
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/QuestionNote/Add',
|
||||
data: {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
},
|
||||
);
|
||||
final payload =
|
||||
(data is Map<String, dynamic> && data.containsKey('data'))
|
||||
? (data['data'] as Map<String, dynamic>?) ?? <String, dynamic>{}
|
||||
: data as Map<String, dynamic>;
|
||||
return NoteImage.fromJson(payload);
|
||||
}
|
||||
|
||||
/// 删除试题笔记 → DELETE /api/v1/QuestionNote/Del/{id}
|
||||
Future<bool> questionNoteDel(dynamic id) async {
|
||||
await DioClient.instance.delete('/api/v1/QuestionNote/Del/$id');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/// ============================================================
|
||||
/// 【笔记图片数据模型】讲义笔记 / 试题笔记
|
||||
///
|
||||
/// NoteImage 对应 Vue 端上传笔记后返回的图片记录:
|
||||
/// - id:笔记记录 id(删除接口用)
|
||||
/// - url:原图 URL
|
||||
/// - thumbnailUrl:缩略图 URL(列表预览用)
|
||||
/// ============================================================
|
||||
|
||||
/// 笔记图片项(用于已上传图片列表)
|
||||
class NoteImage {
|
||||
const NoteImage({
|
||||
this.id,
|
||||
required this.url,
|
||||
this.thumbnailUrl,
|
||||
});
|
||||
|
||||
/// 服务器返回的笔记 id(删除接口用;本地添加的图片可能没有)
|
||||
final dynamic id;
|
||||
|
||||
/// 图片原图 URL
|
||||
final String url;
|
||||
|
||||
/// 缩略图 URL(为空时用 url 兜底)
|
||||
final String? thumbnailUrl;
|
||||
|
||||
factory NoteImage.fromJson(Map<String, dynamic> json) {
|
||||
return NoteImage(
|
||||
id: json['id'],
|
||||
url: (json['url'] as String?) ?? '',
|
||||
thumbnailUrl: json['thumbnailUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// 点击报告(跳转报告页)
|
||||
|
||||
Reference in New Issue
Block a user