flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user