Files
2026-09-04 15:22:20 +08:00

258 lines
8.4 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
),
),
],
),
);
}
}