980 lines
30 KiB
Dart
980 lines
30 KiB
Dart
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<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<UploadPair> 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<UploadPair> 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<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: [
|
||
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<UploadPair> items;
|
||
|
||
/// 打开旋转预览;完成返回旋转后的列表,取消返回 null
|
||
static Future<List<UploadPair>?> open(
|
||
BuildContext context, {
|
||
required List<UploadPair> items,
|
||
}) {
|
||
return Navigator.of(context).push<List<UploadPair>>(
|
||
PageRouteBuilder<List<UploadPair>>(
|
||
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<int> _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<void> _handleConfirm() async {
|
||
if (_busy) return;
|
||
setState(() => _busy = true);
|
||
try {
|
||
final result = <UploadPair>[];
|
||
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<ProcessedImage> _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);
|
||
}
|