import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart' show compute, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:image/image.dart' as img; 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 } /// 选图后的“原始图 + 压缩处理图”配对 typedef UploadPair = ({PickedImage origin, ProcessedImage processed}); /// 图片项(统一承载:本地已�?+ 已上传) 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 validList = []; for (final img in picked) { final ProcessedImage? processed; if (kIsWeb) { // Web:只有字节,走 bytes 压缩 final bytes = img.bytes; if (bytes == null || bytes.isEmpty) continue; processed = await ImageProcessor.process( bytes: bytes, fileName: img.name, maxDimension: 1920, ); } else { // 原生:只传文件路径,原图字节不进 Dart 堆、UI 线程不解码 final path = img.path; if (path == null || path.isEmpty) continue; processed = await ImageProcessor.processFile( path: path, 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; } // AIGrading(上传图片作答):先进入全屏旋转预览, // 用户点“完成”才烘焙旋转并上传;点“取消”则整批放弃(对齐 Vue) List pairsToUpload = validList; if (widget.type == UploadType.aiGrading) { _hideLoading(); processingLoadingShown = false; if (!mounted) return; final rotated = await _ImageRotateEditor.open( context, items: validList, ); if (rotated == null) return; // 用户取消 pairsToUpload = rotated; if (!mounted) return; _showLoading('上传中...'); processingLoadingShown = true; } await _startUpload(pairsToUpload); } 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: [ Stack( children: [ // 标题装饰底图:固定尺寸钉在右下角(对标 background-size: 119rpx auto) Positioned( right: 10.w, bottom: 0, child: Image.network( ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'), width: 59.5.w, height: 9.h, fit: BoxFit.fill, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ), Padding( padding: EdgeInsets.only(right: 14.w), 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)), ), ); } } /// ============================================================ /// 全屏旋转预览(对标 Vue components/ImagePreviewPopup): /// - 左右滑动逐张查看; /// - “↻ 旋转90°”只改当前图片的视觉角度; /// - “完成”才把角度烘焙进图片字节并返回(随后才上传); /// - “取消”/系统返回 → 返回 null,整批放弃。 /// ============================================================ class _ImageRotateEditor extends StatefulWidget { const _ImageRotateEditor({required this.items}); final List items; /// 打开旋转预览;完成返回旋转后的列表,取消返回 null static Future?> open( BuildContext context, { required List items, }) { return Navigator.of(context).push>( PageRouteBuilder>( opaque: true, transitionDuration: const Duration(milliseconds: 200), reverseTransitionDuration: const Duration(milliseconds: 200), pageBuilder: (_, __, ___) => _ImageRotateEditor(items: items), transitionsBuilder: (_, animation, __, child) => FadeTransition(opacity: animation, child: child), ), ); } @override State<_ImageRotateEditor> createState() => _ImageRotateEditorState(); } class _ImageRotateEditorState extends State<_ImageRotateEditor> { late final List _angles = List.filled(widget.items.length, 0); final PageController _pageController = PageController(); int _currentIndex = 0; bool _busy = false; @override void dispose() { _pageController.dispose(); super.dispose(); } void _onPageChanged(int index) => setState(() => _currentIndex = index); void _handleRotate() { if (_busy) return; setState(() { _angles[_currentIndex] = (_angles[_currentIndex] + 90) % 360; }); } Future _handleConfirm() async { if (_busy) return; setState(() => _busy = true); try { final result = []; for (var i = 0; i < widget.items.length; i++) { final pair = widget.items[i]; final angle = _angles[i] % 360; if (angle == 0) { result.add(pair); } else { result.add(( origin: pair.origin, processed: await _bakeRotation(pair.processed, angle), )); } } if (mounted) Navigator.of(context).pop(result); } catch (_) { if (mounted) { setState(() => _busy = false); CenterToast.show(context, '图片处理失败,请重试'); } } } void _handleCancel() { if (_busy) return; Navigator.of(context).pop(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ PageView.builder( controller: _pageController, itemCount: widget.items.length, onPageChanged: _onPageChanged, itemBuilder: (context, index) { return Center( child: AnimatedRotation( turns: _angles[index] / 360, duration: const Duration(milliseconds: 200), child: Image.memory( widget.items[index].processed.bytes, fit: BoxFit.contain, ), ), ); }, ), // 指示点(多图时) if (widget.items.length > 1) Positioned( left: 0, right: 0, bottom: 72.h, child: _buildDots(), ), // 底部操作栏 Positioned( left: 0, right: 0, bottom: 0, child: _buildFooter(), ), // “处理图片中...”遮罩 if (_busy) const ColoredBox( color: Colors.black38, child: Center( child: CircularProgressIndicator(color: Colors.white), ), ), ], ), ); } Widget _buildDots() { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var i = 0; i < widget.items.length; i++) Container( width: 7.w, height: 7.w, margin: EdgeInsets.symmetric(horizontal: 4.w), decoration: BoxDecoration( shape: BoxShape.circle, color: i == _currentIndex ? Colors.white : Colors.white.withValues(alpha: 0.3), ), ), ], ); } Widget _buildFooter() { return SafeArea( top: false, child: Container( padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), decoration: const BoxDecoration(color: Color(0xB3000000)), child: Row( children: [ _FooterTextButton(label: '取消', onTap: _handleCancel), Expanded( child: Center( child: GestureDetector( onTap: _handleRotate, child: Container( padding: EdgeInsets.symmetric( horizontal: 20.w, vertical: 8.h, ), decoration: BoxDecoration( color: const Color(0x26FFFFFF), borderRadius: BorderRadius.circular(30.r), ), child: Text( '↻ 旋转90°', style: TextStyle( fontSize: 14.sp, color: Colors.white, ), ), ), ), ), ), _FooterTextButton(label: '完成', primary: true, onTap: _handleConfirm), ], ), ), ); } } class _FooterTextButton extends StatelessWidget { const _FooterTextButton({ required this.label, required this.onTap, this.primary = false, }); final String label; final VoidCallback onTap; final bool primary; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), child: Text( label, style: TextStyle( fontSize: 15.sp, color: primary ? const Color(0xFF2979FF) : Colors.white, fontWeight: primary ? FontWeight.bold : FontWeight.normal, ), ), ), ); } } /// 旋转 isolate 任务 typedef _RotateTask = ({Uint8List bytes, int angle, bool isPng}); /// 把旋转角度烘焙进 [processed] 的字节(失败时保留原图) Future _bakeRotation( ProcessedImage processed, int angle, ) async { final isPng = processed.contentType == 'image/png'; final rotated = await compute<_RotateTask, Uint8List?>( _rotateInIsolate, (bytes: processed.bytes, angle: angle, isPng: isPng), ); if (rotated == null || rotated.isEmpty) return processed; return ProcessedImage( bytes: rotated, extension: isPng ? 'png' : 'jpg', contentType: isPng ? 'image/png' : 'image/jpeg', originalBytes: processed.originalBytes, processedBytes: rotated.length, ); } /// isolate 内:解码 → 顺时针旋转 → 编码(JPEG 质量 90,对齐 Vue canvas 0.9) Uint8List? _rotateInIsolate(_RotateTask task) { final src = img.decodeImage(task.bytes); if (src == null) return null; final dst = img.copyRotate(src, angle: task.angle); final encoded = task.isPng ? img.encodePng(dst) : img.encodeJpg(dst, quality: 90); return Uint8List.fromList(encoded); }