diff --git a/lib/core/api/conquerPackage/beforeDuringClass/api.dart b/lib/core/api/conquerPackage/beforeDuringClass/api.dart new file mode 100644 index 0000000..d7903e2 --- /dev/null +++ b/lib/core/api/conquerPackage/beforeDuringClass/api.dart @@ -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 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 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 getSubmitReport(String recordId) async { + final data = await DioClient.instance.get( + '/api/v1/StudyPack/GetReport/$recordId', + ); + // 报告里 handouts 可能在 data.handouts 下 + if (data is Map) { + final payload = data['data'] as Map? ?? data; + return HandoutListResponse.fromJson(payload['handouts']); + } + return const HandoutListResponse(); + } +} diff --git a/lib/core/api/conquerPackage/beforeDuringClass/model.dart b/lib/core/api/conquerPackage/beforeDuringClass/model.dart new file mode 100644 index 0000000..a72a541 --- /dev/null +++ b/lib/core/api/conquerPackage/beforeDuringClass/model.dart @@ -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 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 materials; // 材料列表 + final List 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? materials, + List? 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 json) { + final materials = (json['materials'] as List? ?? const []) + .whereType>() + .map(HandoutMaterial.fromJson) + .toList(); + final notes = (json['notes'] as List? ?? const []) + .whereType>() + .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 items; + + /// 兼容响应壳 {code, message, data: [...]},也兼容直接返回数组 + factory HandoutListResponse.fromJson(dynamic data) { + List list; + if (data is List) { + list = data; + } else if (data is Map) { + final payload = data['data'] ?? data['items'] ?? data['handouts']; + list = (payload is List) ? payload : const []; + } else { + list = const []; + } + return HandoutListResponse( + items: list + .whereType>() + .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; +} diff --git a/lib/core/api/public_upload/api.dart b/lib/core/api/public_upload/api.dart new file mode 100644 index 0000000..c7b5fba --- /dev/null +++ b/lib/core/api/public_upload/api.dart @@ -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 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 && data.containsKey('data')) + ? (data['data'] as Map?) ?? {} + : data as Map; + return NoteImage.fromJson(payload); + } + + /// 删除讲义笔记 → DELETE /api/v1/HandoutNote/Del/{id} + /// + /// 返回 true 表示删除成功 + Future handoutNoteDel(dynamic id) async { + await DioClient.instance.delete('/api/v1/HandoutNote/Del/$id'); + return true; + } + + /// 添加试题笔记 → POST /api/v1/QuestionNote/Add + Future 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 && data.containsKey('data')) + ? (data['data'] as Map?) ?? {} + : data as Map; + return NoteImage.fromJson(payload); + } + + /// 删除试题笔记 → DELETE /api/v1/QuestionNote/Del/{id} + Future questionNoteDel(dynamic id) async { + await DioClient.instance.delete('/api/v1/QuestionNote/Del/$id'); + return true; + } +} diff --git a/lib/core/api/public_upload/model.dart b/lib/core/api/public_upload/model.dart new file mode 100644 index 0000000..6baf166 --- /dev/null +++ b/lib/core/api/public_upload/model.dart @@ -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 json) { + return NoteImage( + id: json['id'], + url: (json['url'] as String?) ?? '', + thumbnailUrl: json['thumbnailUrl'] as String?, + ); + } + + Map toJson() => { + 'id': id, + 'url': url, + 'thumbnailUrl': thumbnailUrl, + }; +} diff --git a/lib/core/components/audio_player_card.dart b/lib/core/components/audio_player_card.dart index 28e3ef8..e37c10d 100644 --- a/lib/core/components/audio_player_card.dart +++ b/lib/core/components/audio_player_card.dart @@ -84,10 +84,10 @@ class AudioPlayerCard extends StatefulWidget { final List playbackSpeeds; @override - State createState() => _AudioPlayerCardState(); + AudioPlayerCardState createState() => AudioPlayerCardState(); } -class _AudioPlayerCardState extends State +class AudioPlayerCardState extends State with WidgetsBindingObserver { /// 底层播放器(just_audio) final AudioPlayer _player = AudioPlayer(); @@ -123,6 +123,11 @@ class _AudioPlayerCardState extends State super.dispose(); } + /// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停音频) + void pause() { + _player.pause(); + } + /// App 切后台 / 来电 → 自动暂停 @override void didChangeAppLifecycleState(AppLifecycleState state) { diff --git a/lib/core/components/upload.dart b/lib/core/components/upload.dart new file mode 100644 index 0000000..b2be613 --- /dev/null +++ b/lib/core/components/upload.dart @@ -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? 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 initialImageList; + + /// 上传类型 + final UploadType type; + + /// 业务 id(讲�?id / 题目 id�? + final dynamic ids; + + /// 列表变化回调 + final ValueChanged>? onChanged; + + @override + State createState() => UploadState(); +} + +class UploadState extends State { + final List _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 chooseImage() async => _chooseImage(); + + /// 获取当前图片列表 + List 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( + 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 _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 _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 _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 _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)), + ), + ); + } +} diff --git a/lib/core/components/video_player_view.dart b/lib/core/components/video_player_view.dart index 62d1a98..c33fe60 100644 --- a/lib/core/components/video_player_view.dart +++ b/lib/core/components/video_player_view.dart @@ -107,10 +107,10 @@ class VideoPlayerView extends StatefulWidget { final ChewieProgressColors? materialProgressColors; @override - State createState() => _VideoPlayerViewState(); + VideoPlayerViewState createState() => VideoPlayerViewState(); } -class _VideoPlayerViewState extends State +class VideoPlayerViewState extends State with WidgetsBindingObserver { /// 官方底层控制器 VideoPlayerController? _videoController; @@ -144,6 +144,11 @@ class _VideoPlayerViewState extends State super.dispose(); } + /// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停视频) + void pause() { + _videoController?.pause(); + } + /// App 切后台 / 来电 → 自动暂停,防止声音继续播 @override void didChangeAppLifecycleState(AppLifecycleState state) { diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index f70eef8..46bbaea 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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?, + ), + ), GoRoute(path: '/register', builder: (_, _) => const RegisterPage()), // app_router.dart 的 routes 里加,不用管跳转逻辑 diff --git a/lib/features/conquerPackage/beforeDuringClass/before_during_page.dart b/lib/features/conquerPackage/beforeDuringClass/before_during_page.dart new file mode 100644 index 0000000..71ac4d6 --- /dev/null +++ b/lib/features/conquerPackage/beforeDuringClass/before_during_page.dart @@ -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>? onUploadChanged; + + @override + State createState() => BeforeDuringPageState(); +} + +class BeforeDuringPageState extends State + with AutomaticKeepAliveClientMixin { + final GlobalKey _uploadKey = GlobalKey(); + + @override + bool get wantKeepAlive => true; + + // ===================== 对外暴露方法 ===================== + + /// 触发上传笔记 + void uploadLectureNotes() { + _uploadKey.currentState?.chooseImage(); + } + + /// 获取当前笔记列表 + List 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, + ); + } +} diff --git a/lib/features/conquerPackage/beforeDuringClass/index.dart b/lib/features/conquerPackage/beforeDuringClass/index.dart index d121eb0..8e059bf 100644 --- a/lib/features/conquerPackage/beforeDuringClass/index.dart +++ b/lib/features/conquerPackage/beforeDuringClass/index.dart @@ -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? extra; + + @override + State createState() => _BeforeDuringClassPageState(); +} + +class _BeforeDuringClassPageState extends State + 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> _pageKeys = {}; + + // ========== 路由参数 ========== + late BeforeDuringMode _mode; + late String _courseId; + late String _packId; + late String _recordId; + bool _isWrong = false; + + // ========== 数据 ========== + List _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 {}; + _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 _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.from(resp.items); + // _pageList = List.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( + 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 _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 _onPrevious() async { + if (_currentIndex == 0) return; + _saveCurrentPageRemainTime(); + _pausePageMedia(_currentIndex); + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + Future _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 _finishAll() async { + _isNavigatingAway = true; + _saveCurrentPageRemainTime(); + _pausePageMedia(_currentIndex); + await _renewHandoutTimeForIndex(_currentIndex); + if (mounted) { + _goTransitionPage(); + } + } + + void _goTransitionPage() { + // TODO: 跳转到 transition 页,等路由配置好后替换 + Navigator.of(context).pop(); + } + + // ===================== 笔记变化 ===================== + + void _onUploadChanged(List 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 _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(); + 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, + ), + ), + ), + ), + ), + ], + ); + } } diff --git a/lib/features/conquerPackage/captureList/index.dart b/lib/features/conquerPackage/captureList/index.dart index fbdbc60..e8d3912 100644 --- a/lib/features/conquerPackage/captureList/index.dart +++ b/lib/features/conquerPackage/captureList/index.dart @@ -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 { // 跳转分支(对标 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 { } } - /// 页面跳转(占位 —— 待路由统一后替换为正式路由名) + /// 页面跳转(go_router push + extra 传参) void _navigateTo(String path, Map 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); } /// 点击报告(跳转报告页)