This commit is contained in:
chenshuainan
2026-09-28 20:12:34 +08:00
parent 847986f861
commit 5f53bd8f94
60 changed files with 14624 additions and 398 deletions
+335 -30
View File
@@ -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);
}