更新
This commit is contained in:
+335
-30
@@ -1,8 +1,10 @@
|
||||
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';
|
||||
@@ -58,6 +60,9 @@ enum UploadMode { edit, view }
|
||||
/// 上传组件类型
|
||||
enum UploadType { handouts, questions, aiGrading, feedback }
|
||||
|
||||
/// 选图后的“原始图 + 压缩处理图”配对
|
||||
typedef UploadPair = ({PickedImage origin, ProcessedImage processed});
|
||||
|
||||
/// 图片项(统一承载:本地已�?+ 已上传)
|
||||
class UploadItem {
|
||||
const UploadItem({
|
||||
@@ -267,18 +272,28 @@ class UploadState extends State<Upload> {
|
||||
processingLoadingShown = true;
|
||||
|
||||
// 压缩 + EXIF 修正 + 尺寸过滤
|
||||
final List<({PickedImage origin, ProcessedImage processed})> validList =
|
||||
[];
|
||||
final List<UploadPair> 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,
|
||||
);
|
||||
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;
|
||||
@@ -301,9 +316,27 @@ class UploadState extends State<Upload> {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: AIGrading 类型的旋转预览弹窗待实现(Vue 端有 $showImagePreview�?
|
||||
// 暂时直接上传,后续有需求再�?
|
||||
await _startUpload(validList);
|
||||
// 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');
|
||||
@@ -488,7 +521,7 @@ class UploadState extends State<Upload> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 模式�?view 且没有图片时不显示(对齐 Vue v-if�?
|
||||
// 模式:view 且没有图片时不显示(对齐 Vue v-if)
|
||||
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
|
||||
return _buildUploadBox();
|
||||
}
|
||||
@@ -520,31 +553,38 @@ class UploadState extends State<Upload> {
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.only(right: 14.w),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
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(),
|
||||
),
|
||||
alignment: Alignment.bottomRight,
|
||||
repeat: ImageRepeat.noRepeat,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1D2B47),
|
||||
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 张',
|
||||
: '共 ${_imageList.length} 张',
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
|
||||
),
|
||||
],
|
||||
@@ -672,3 +712,268 @@ class UploadState extends State<Upload> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 全屏旋转预览(对标 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user