258 lines
8.4 KiB
Dart
258 lines
8.4 KiB
Dart
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<OssUploadDemoPage> createState() => _OssUploadDemoPageState();
|
||
}
|
||
|
||
class _OssUploadDemoPageState extends State<OssUploadDemoPage> {
|
||
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<void> _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<void> _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)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|