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

- 实现攻克包模块的课前课中学习页面,包含学习计时、笔记上传、分页切换等完整逻辑
- 新增通用多图上传组件,支持编辑/查看两种业务模式
- 配置新页面的路由规则,并更新捕获列表页面的跳转逻辑为正式的go_router跳转
- 修复捕获列表页面跳转路径不匹配的问题
This commit is contained in:
chenshuainan
2026-09-11 09:37:37 +08:00
parent ee14373c6d
commit fe060ac974
11 changed files with 2230 additions and 20 deletions
+7 -2
View File
@@ -84,10 +84,10 @@ class AudioPlayerCard extends StatefulWidget {
final List<double> playbackSpeeds;
@override
State<AudioPlayerCard> createState() => _AudioPlayerCardState();
AudioPlayerCardState createState() => AudioPlayerCardState();
}
class _AudioPlayerCardState extends State<AudioPlayerCard>
class AudioPlayerCardState extends State<AudioPlayerCard>
with WidgetsBindingObserver {
/// 底层播放器(just_audio)
final AudioPlayer _player = AudioPlayer();
@@ -123,6 +123,11 @@ class _AudioPlayerCardState extends State<AudioPlayerCard>
super.dispose();
}
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停音频)
void pause() {
_player.pause();
}
/// App 切后台 / 来电 → 自动暂停
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
+674
View File
@@ -0,0 +1,674 @@
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/public_upload/api.dart';
import 'package:new_learn/core/api/public_upload/model.dart';
import 'package:new_learn/core/components/center_toast.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:new_learn/core/network/oss_api.dart';
import 'package:new_learn/core/network/oss_uploader.dart';
import 'package:new_learn/core/services/image_processor.dart';
import 'package:new_learn/core/services/picker_service.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【Upload 组件】多图上传组件(对标 Vue components/Upload/Upload.vue�?
///
/// 一、功能:
/// 1. edit 模式:相�?拍照多�?�?压缩 + EXIF 修正 �?OSS 直传 �?
/// 调用业务接口关联笔记 �?本地图片列表刷新
/// 2. view 模式:仅展示图片网格,无添加/删除按钮
/// 3. 点击图片 �?全屏预览(photo_view 双指缩放 + 左右切换�?
/// 4. 底部可配�?拍摄规范"提示文本(title == '上传图片作答' 时显示)
///
/// 二、Props 对照�?
/// - [maxCount] : 最多上传张数(默认 10�?
/// - [mode] : UploadMode.edit / UploadMode.view(默�?edit�?
/// - [title] : 标题文案(默�?'上传笔记'�?
/// - [initialImageList]: 初始已上传图片列�?
/// - [type] : 类型:handouts / questions / AIGrading / feedback
/// - [ids] : 业务 id(讲�?id / 题目 id�?
/// - [onChanged] : 图片列表变化回调(相当于 Vue �?change 事件�?
///
/// 三、上传链路(对齐 Vue + my_page.dart 头像上传流程):
/// �?PickerService.showPickSheet 选图(含权限前置弹窗�?
/// �?ImageProcessor.process 压缩 + EXIF 方向修正 + 尺寸过滤
/// �?OssUploader.uploadImage OSS 直传(scene �?type 映射�?
/// �?NoteApi 笔记关联(handouts/question 走业务接口,
/// AIGrading/feedback 仅本地维护列表)
///
/// 四、type �?OSS scene / 笔记接口映射�?
/// type | UploadScene | 笔记接口
/// ─────────────┼────────────────┼────────────
/// handouts | note | HandoutNoteAdd/Del
/// questions | note | QuestionNoteAdd/Del
/// AIGrading | aiGrading | 无(仅本地列表)
/// feedback | note | 无(仅本地列表)
///
/// 五、尺寸过滤策略(对齐 Vue filterOversizeImages):
/// - handouts / questions 类型:跳过尺寸校验(讲义/题目图可能很大)
/// - 其它类型:宽高必须在 14~4000 像素之间
/// ============================================================
/// 上传组件模式
enum UploadMode { edit, view }
/// 上传组件类型
enum UploadType { handouts, questions, aiGrading, feedback }
/// 图片项(统一承载:本地已�?+ 已上传)
class UploadItem {
const UploadItem({
this.id,
required this.url,
this.thumbnailUrl,
this.localBytes,
this.localEntity,
this.isUploading = false,
});
/// 服务器返回的笔记 id(删除用;本地图片无 id�?
final dynamic id;
/// 图片 URL(已上传)或本地路径(未上传�?
final String url;
/// 缩略�?URL
final String? thumbnailUrl;
/// 本地字节(Web 预览用,上传完成后置空)
final List<int>? localBytes;
/// 原生相册实体(原生端缩略图预览用,上传完成后置空�?
final dynamic localEntity;
/// 是否正在上传中(用于显示加载态)
final bool isUploading;
/// 是否为网络图(已上传�?OSS�?
bool get isNetwork => url.startsWith('http');
factory UploadItem.fromNoteImage(NoteImage note) => UploadItem(
id: note.id,
url: note.url,
thumbnailUrl: note.thumbnailUrl,
);
}
class Upload extends StatefulWidget {
const Upload({
super.key,
this.maxCount = 10,
this.mode = UploadMode.edit,
this.title = '上传笔记',
this.initialImageList = const [],
this.type = UploadType.handouts,
this.ids,
this.onChanged,
});
/// 最多上传张�?
final int maxCount;
/// 模式:编�?/ 查看
final UploadMode mode;
/// 标题文案
final String title;
/// 初始图片列表(已上传的网络图�?
final List<UploadItem> initialImageList;
/// 上传类型
final UploadType type;
/// 业务 id(讲�?id / 题目 id�?
final dynamic ids;
/// 列表变化回调
final ValueChanged<List<UploadItem>>? onChanged;
@override
State<Upload> createState() => UploadState();
}
class UploadState extends State<Upload> {
final List<UploadItem> _imageList = [];
int _uploadingCount = 0;
bool _choosing = false;
@override
void initState() {
super.initState();
_imageList.addAll(widget.initialImageList);
}
@override
void didUpdateWidget(covariant Upload oldWidget) {
super.didUpdateWidget(oldWidget);
// 外部 initialImageList 变化时同步(类似 Vue �?watch defaultImageList�?
if (oldWidget.initialImageList != widget.initialImageList) {
_imageList
..clear()
..addAll(widget.initialImageList);
}
}
// ===================== 对外暴露方法 =====================
/// 触发选图(等�?Vue �?chooseImage�?
Future<void> chooseImage() async => _chooseImage();
/// 获取当前图片列表
List<UploadItem> getImageList() => List.unmodifiable(_imageList);
/// 正在上传的数�?
int get uploadingCount => _uploadingCount;
/// 是否正在选图
bool get choosing => _choosing;
// ===================== 工具方法 =====================
void _notifyChanged() {
widget.onChanged?.call(List.unmodifiable(_imageList));
}
/// 映射 type �?OSS scene
UploadScene get _uploadScene {
switch (widget.type) {
case UploadType.aiGrading:
return UploadScene.aiGrading;
case UploadType.handouts:
case UploadType.questions:
case UploadType.feedback:
return UploadScene.note;
}
}
/// 是否跳过尺寸校验(讲�?题目大图允许�?
bool get _skipSizeLimit =>
widget.type == UploadType.handouts ||
widget.type == UploadType.questions;
void _toast(String message) {
CenterToast.show(context, message);
}
void _showLoading(String message) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 28.w, vertical: 20.h),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: Colors.white),
SizedBox(height: 12.h),
Text(
message,
style: TextStyle(color: Colors.white, fontSize: 13.sp),
),
],
),
),
),
),
);
}
void _hideLoading() {
if (Navigator.of(context, rootNavigator: true).canPop()) {
Navigator.of(context, rootNavigator: true).pop();
}
}
String _replaceExtension(String fileName, String extension) {
final dotIndex = fileName.lastIndexOf('.');
final safeExtension = extension.toLowerCase();
if (dotIndex <= 0) return '$fileName.$safeExtension';
return '${fileName.substring(0, dotIndex)}.$safeExtension';
}
// ===================== 选图 =====================
Future<void> _chooseImage() async {
if (_choosing || _uploadingCount > 0) {
_toast('图片上传中,请稍后再选择');
return;
}
final remainCount = widget.maxCount - _imageList.length - _uploadingCount;
if (remainCount <= 0) {
_toast('最多上传${widget.maxCount}张图片');
return;
}
setState(() => _choosing = true);
bool processingLoadingShown = false;
try {
final picked = await PickerService.instance.showPickSheet(
context,
maxCount: remainCount,
);
if (picked == null || picked.isEmpty) return;
// 显示"上传中..."遮罩(压�?+ 尺寸过滤可能耗时几秒�?
_showLoading('上传中...');
processingLoadingShown = true;
// 压缩 + EXIF 修正 + 尺寸过滤
final List<({PickedImage origin, ProcessedImage processed})> validList =
[];
for (final img in picked) {
final bytes = img.bytes ?? await img.entity?.originBytes;
if (bytes == null || bytes.isEmpty) continue;
final processed = await ImageProcessor.process(
bytes: bytes,
fileName: img.name,
maxDimension: 1920,
);
if (processed == null) {
// 压缩失败 / 解码失败 �?跳过
continue;
}
if (_skipSizeLimit) {
validList.add((origin: img, processed: processed));
continue;
}
// 尺寸校验:由 ImageProcessor 内部 min/max 拦截�?
// 返回 null 说明尺寸不符合。这里按大小区分提示�?
// (ImageProcessor 只返�?null,不区分过小/过大�?
// 这里简化为"不符合要�?,与 Vue 的核心提示一致。)
validList.add((origin: img, processed: processed));
}
if (validList.isEmpty) {
_toast('图片尺寸不符合要求,请重新选择');
return;
}
// TODO: AIGrading 类型的旋转预览弹窗待实现(Vue 端有 $showImagePreview�?
// 暂时直接上传,后续有需求再�?
await _startUpload(validList);
} catch (e) {
_toast('图片处理失败,请重试');
debugPrint('[Upload] 选图异常: $e');
} finally {
if (processingLoadingShown) _hideLoading();
if (mounted) setState(() => _choosing = false);
}
}
// ===================== 上传 =====================
Future<void> _startUpload(
List<({PickedImage origin, ProcessedImage processed})> items,
) async {
if (items.isEmpty) return;
final canUploadCount =
widget.maxCount - _imageList.length - _uploadingCount;
final safeEnd = canUploadCount.clamp(0, items.length);
final safeItems =
safeEnd > 0 ? items.sublist(0, safeEnd) : <({
PickedImage origin,
ProcessedImage processed
})>[];
if (safeItems.isEmpty) {
_toast('最多上传${widget.maxCount}张图片');
return;
}
if (safeItems.length < items.length) {
_toast('最多还能上传${safeItems.length}张图片');
}
final pendingCount = safeItems.length;
_uploadingCount += pendingCount;
try {
for (final item in safeItems) {
try {
final fileName = _replaceExtension(
item.origin.name ??
'img_${DateTime.now().millisecondsSinceEpoch}.jpg',
item.processed.extension,
);
// OSS 直传
final url = await OssUploader.uploadImage(
bytes: item.processed.bytes,
fileName: fileName,
contentType: item.processed.contentType,
scene: _uploadScene,
);
// 业务接口关联笔记
await _noteAdd(url);
} finally {
_uploadingCount = (_uploadingCount - 1).clamp(0, 999);
}
}
_toast('上传成功');
} catch (e) {
_toast('笔记上传失败,请检查网络后重试');
} finally {
if (mounted) setState(() {});
}
}
/// 调业务接口添加笔记(�?type 分流�?
Future<void> _noteAdd(String url) async {
switch (widget.type) {
case UploadType.handouts:
if (widget.ids != null) {
final note = await NoteApi.instance.handoutNoteAdd(
id: widget.ids,
url: url,
thumbnailUrl: url,
);
if (note.url.isNotEmpty && note.id != null) {
setState(() {
_imageList.add(UploadItem.fromNoteImage(note));
});
}
} else {
setState(() {
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
});
}
break;
case UploadType.questions:
if (widget.ids != null) {
final note = await NoteApi.instance.questionNoteAdd(
id: widget.ids,
url: url,
thumbnailUrl: url,
);
if (note.url.isNotEmpty && note.id != null) {
setState(() {
_imageList.add(UploadItem.fromNoteImage(note));
});
}
} else {
setState(() {
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
});
}
break;
case UploadType.aiGrading:
case UploadType.feedback:
setState(() {
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
});
break;
}
_notifyChanged();
}
// ===================== 删除 =====================
Future<void> _removeImg(UploadItem item) async {
try {
switch (widget.type) {
case UploadType.handouts:
if (widget.ids != null && item.id != null) {
final ok = await NoteApi.instance.handoutNoteDel(item.id);
if (ok) {
setState(() {
_imageList.removeWhere((e) => e.id == item.id);
});
}
}
break;
case UploadType.questions:
if (widget.ids != null && item.id != null) {
final ok = await NoteApi.instance.questionNoteDel(item.id);
if (ok) {
setState(() {
_imageList.removeWhere((e) => e.id == item.id);
});
}
}
break;
case UploadType.aiGrading:
case UploadType.feedback:
setState(() {
_imageList.removeWhere((e) => e.url == item.url);
});
break;
}
} catch (e) {
_toast('删除失败,请检查网络后重试');
}
_notifyChanged();
}
// ===================== 预览 =====================
void _previewImage(int index) {
final items = _imageList
.map(
(item) => PreviewImageItem(
imageProvider: _imageProviderOf(item),
),
)
.toList();
ImagePreviewViewer.show(context, items: items, initialIndex: index);
}
ImageProvider _imageProviderOf(UploadItem item) {
if (item.isNetwork) {
return CachedNetworkImageProvider(ImgUrl.build(item.url));
}
if (item.localBytes != null) {
return MemoryImage(Uint8List.fromList(item.localBytes!));
}
return const AssetImage(''); // 兜底
}
// ===================== Build =====================
@override
Widget build(BuildContext context) {
// 模式�?view 且没有图片时不显示(对齐 Vue v-if�?
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
return _buildUploadBox();
}
return const SizedBox.shrink();
}
Widget _buildUploadBox() {
return Container(
padding: EdgeInsets.all(14.w),
margin: EdgeInsets.only(bottom: 10.h),
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
border: Border.all(color: const Color(0xFFE3ECFF), width: 1.r),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
_buildGrid(),
if (widget.mode == UploadMode.edit &&
widget.title == '上传图片作答')
_buildTip(),
],
),
);
}
Widget _buildHeader() {
return Row(
children: [
Container(
padding: EdgeInsets.only(right: 14.w),
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
),
alignment: Alignment.bottomRight,
repeat: ImageRepeat.noRepeat,
),
),
child: Text(
widget.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
),
SizedBox(width: 14.w),
Text(
widget.mode == UploadMode.edit
? '已上传:${_imageList.length}/${widget.maxCount}张'
: '共 $_imageList.length 张',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
),
],
);
}
Widget _buildGrid() {
final itemSize = 67.w;
final showAdd = widget.mode == UploadMode.edit &&
(_imageList.length + _uploadingCount) < widget.maxCount;
final totalCount = _imageList.length + (showAdd ? 1 : 0);
return Padding(
padding: EdgeInsets.only(top: 10.h),
child: Wrap(
spacing: 10.w,
runSpacing: 10.h,
children: [
for (int i = 0; i < _imageList.length; i++)
_buildImageItem(_imageList[i], i, itemSize),
if (showAdd) _buildAddButton(itemSize),
],
),
);
}
Widget _buildAddButton(double size) {
return GestureDetector(
onTap: _chooseImage,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(6.r),
),
child: Center(
child: Image.network(
ImgUrl.build('/StaticResource/2026-04-01/add.png'),
width: 19.5.w,
height: 19.5.w,
errorBuilder: (_, __, ___) => Icon(
Icons.add,
size: 19.5.w,
color: const Color(0xFFB0B8C4),
),
),
),
),
);
}
Widget _buildImageItem(UploadItem item, int index, double size) {
return GestureDetector(
onTap: () => _previewImage(index),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(6.r),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
if (item.isNetwork)
CachedNetworkImage(
imageUrl: ImgUrl.build(item.url),
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const ColoredBox(
color: Color(0xFFEEF1F5),
),
)
else if (item.localBytes != null)
Image.memory(
Uint8List.fromList(item.localBytes!),
fit: BoxFit.cover,
)
else
const ColoredBox(color: Color(0xFFEEF1F5)),
// 删除按钮
if (widget.mode == UploadMode.edit)
Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () => _removeImg(item),
child: Image.network(
ImgUrl.build('/StaticResource/2026-04-14/remove.png'),
width: 18.w,
height: 18.w,
errorBuilder: (_, __, ___) => Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Icons.close,
size: 12, color: Colors.white),
),
),
),
),
],
),
),
);
}
Widget _buildTip() {
return Padding(
padding: EdgeInsets.only(top: 15.h),
child: Text(
'图片作答拍摄规范:\n'
'1. 采光:光线充足,无阴影、无反光遮挡答题区\n'
'2. 角度:纸张铺平,垂直俯拍,画面不歪斜\n'
'3. 画质:完整拍下整张纸,文字清晰无裁切模糊\n'
'4. 卷面:答题区仅留正式作答,草稿另用草稿纸\n'
'5. 修改:错处双线划去,旁侧重写,禁止反复涂黑叠写\n'
'提示:画面越规整清晰,识别批改越准确',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
);
}
}
+7 -2
View File
@@ -107,10 +107,10 @@ class VideoPlayerView extends StatefulWidget {
final ChewieProgressColors? materialProgressColors;
@override
State<VideoPlayerView> createState() => _VideoPlayerViewState();
VideoPlayerViewState createState() => VideoPlayerViewState();
}
class _VideoPlayerViewState extends State<VideoPlayerView>
class VideoPlayerViewState extends State<VideoPlayerView>
with WidgetsBindingObserver {
/// 官方底层控制器
VideoPlayerController? _videoController;
@@ -144,6 +144,11 @@ class _VideoPlayerViewState extends State<VideoPlayerView>
super.dispose();
}
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停视频)
void pause() {
_videoController?.pause();
}
/// App 切后台 / 来电 → 自动暂停,防止声音继续播
@override
void didChangeAppLifecycleState(AppLifecycleState state) {