import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.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/picker_service.dart'; /// ============================================================ /// 【OssUploadDemoPage】OSS 直传 Demo(客户端签名 + PUT 上传) /// /// 一、演示链路(与 UniApp ossUpload.js 完全对照): /// 选图 → 后端分配路径(GetXxxOSSUploadUrl) → 拿 STS(GetAliOSSSetting) /// → V4 签名 → PUT 直传 OSS → 显示返回的 CDN URL + 上传进度。 /// /// 二、双端读取图片字节: /// web :PickedImage.bytes(image_picker 已读好) /// 原生 :AssetEntity.originBytes(photo_manager 纯 Dart API) /// 这样本页不用 import dart:io,Web 也能编译。 /// /// 三、注意: /// 本 demo 需要后端 OSS 接口可访问(本地/测试环境), /// 403 = 签名或 STS 有问题(看 LogInterceptor 打印的响应体定位)。 /// ============================================================ class OssUploadDemoPage extends StatefulWidget { const OssUploadDemoPage({super.key}); @override State createState() => _OssUploadDemoPageState(); } class _OssUploadDemoPageState extends State { UploadScene _scene = UploadScene.avatar; PickedImage? _image; bool _uploading = false; double _progress = 0; String? _resultUrl; String? _error; /// 场景显示名(对应 UniApp mode) static String _sceneName(UploadScene s) => switch (s) { UploadScene.avatar => '头像', UploadScene.note => '笔记', UploadScene.aiGrading => 'AI判题', }; /// 选图(单张) Future _pick() async { final picked = await PickerService.instance.showPickSheet(context, maxCount: 1); if (picked == null || picked.isEmpty) return; setState(() { _image = picked.first; _resultUrl = null; _error = null; _progress = 0; }); } /// 上传:读字节 → 4 步链路直传 OSS Future _upload() async { final image = _image; if (image == null) return; // 读图片字节:web 用 bytes,原生用 AssetEntity.originBytes(无需 dart:io) final Uint8List bytes = image.bytes ?? await image.entity?.originBytes ?? Uint8List(0); if (bytes.isEmpty) { setState(() => _error = '无法读取图片数据,请重新选择'); return; } final fileName = image.name ?? 'image_${DateTime.now().millisecondsSinceEpoch}.jpg'; setState(() { _uploading = true; _progress = 0; _error = null; _resultUrl = null; }); try { final url = await OssUploader.uploadImage( bytes: bytes, fileName: fileName, scene: _scene, onSendProgress: (sent, total) { if (total > 0) { setState(() => _progress = sent / total); } }, ); if (!mounted) return; setState(() { _uploading = false; _progress = 1; _resultUrl = url; }); } catch (e) { if (!mounted) return; setState(() { _uploading = false; _error = e.toString(); }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('OSS 直传'), centerTitle: true), body: ListView( padding: EdgeInsets.all(16.w), children: [ _tip( '链路:选图 → 后端分配路径(GetXxxOSSUploadUrl) → 拿STS' '(GetAliOSSSetting) → V4签名 → PUT直传OSS。\n' '需要后端 OSS 接口可访问;403 看控制台响应体定位签名问题。', ), SizedBox(height: 20.h), // ─── 场景选择 ─── Text('上传场景', style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: const Color(0xFF333333))), SizedBox(height: 8.h), Wrap( spacing: 10.w, children: UploadScene.values .map((s) => ChoiceChip( label: Text(_sceneName(s)), selected: _scene == s, onSelected: _uploading ? null : (_) => setState(() => _scene = s), )) .toList(), ), SizedBox(height: 20.h), // ─── 已选图片 ─── Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12.r), border: Border.all(color: const Color(0xFFEEF1F5)), ), child: _image == null ? Text('未选择图片', style: TextStyle( fontSize: 13.sp, color: const Color(0xFF999999))) : Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(8.r), child: PickerService.buildThumb( _image!, width: 80.w, height: 80.h, ), ), SizedBox(width: 12.w), Expanded( child: Text( _image!.name ?? '已选择图片', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, color: const Color(0xFF333333)), ), ), ], ), ), SizedBox(height: 12.h), // ─── 操作按钮 ─── Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _uploading ? null : _pick, icon: const Icon(Icons.photo_library_outlined), label: const Text('选择图片'), ), ), SizedBox(width: 12.w), Expanded( child: FilledButton.icon( onPressed: (_image == null || _uploading) ? null : _upload, icon: const Icon(Icons.cloud_upload_outlined), label: Text(_uploading ? '上传中...' : '上传到 OSS'), ), ), ], ), SizedBox(height: 20.h), // ─── 进度条 ─── if (_uploading) ...[ LinearProgressIndicator(value: _progress, minHeight: 6), SizedBox(height: 6.h), Text( '${(_progress * 100).toStringAsFixed(0)}%', style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)), ), SizedBox(height: 20.h), ], // ─── 结果 ─── if (_resultUrl != null) ...[ _tip('上传成功!访问地址:', icon: Icons.check_circle, color: const Color(0xFF10B981)), SizedBox(height: 8.h), SelectableText( _resultUrl!, style: TextStyle(fontSize: 12.sp, color: const Color(0xFF3B82F6)), ), ], if (_error != null) ...[ _tip('上传失败:$_error', icon: Icons.error_outline, color: const Color(0xFFEF4444)), ], ], ), ); } Widget _tip(String text, {IconData? icon, Color? color}) { final accent = color ?? const Color(0xFF3B82F6); return Container( padding: EdgeInsets.all(12.w), decoration: BoxDecoration( color: const Color(0xFFE8F0FE), borderRadius: BorderRadius.circular(10.r), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (icon != null) ...[ Icon(icon, size: 18, color: accent), SizedBox(width: 8.w), ], Expanded( child: Text( text, style: TextStyle(fontSize: 12.sp, color: const Color(0xFF666666)), ), ), ], ), ); } }