feat(mobile-app): 增强合同PDF下载可靠性和用户体验
- PDF下载增加10次自动重试机制,使用指数退避策略 - 超时时间延长至300秒,适应大文件和慢网络 - 新增下载进度显示(百分比圆环) - 失败后显示重试按钮,区分任务加载错误和PDF下载错误 - ApiClient.get方法新增cancelToken和onReceiveProgress参数支持 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f7b2267583
commit
7a4a207bed
|
|
@ -664,7 +664,18 @@
|
|||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(planting-service\\): 修复合同PDF签署日期显示为UTC时间的问题\n\n合同生成时使用 new Date\\(\\).toISOString\\(\\).split\\(''T''\\)[0] 获取日期,\n该方法返回UTC时间,导致北京时间凌晨签署的合同显示为前一天日期。\n\n修复方案:新增 getBeijingDateString\\(\\) 函数,将UTC时间转换为北京时间\\(UTC+8\\)\n\n影响范围:仅影响PDF合同上显示的签署日期,不影响数据库时间戳或业务逻辑\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin main)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" tag -a v1.0.0 -m \"$\\(cat <<''EOF''\nRelease v1.0.0 - 正式发布\n\n主要功能:\n- 用户身份认证与KYC实名认证\n- 榴莲树认种与合同签署系统\n- 钱包与资产管理(USDT/绿积分/算力)\n- 推荐关系与团队管理\n- 收益分配与奖励系统\n- 排行榜系统\n- 后台管理系统\n- MPC多方计算钱包\n- 区块链服务(KAVA链)\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin v1.0.0)"
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin v1.0.0)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 修复用户数据CDC同步使用userId导致的数据不一致问题\n\n问题原因:\n- 旧的Kafka事件消费者和CDC消费者同时运行\n- 旧消费者写入的数据userId可能为0\n- CDC消费者使用userId作为upsert条件,导致唯一键冲突失败\n- 用户的nickname和kycStatus等信息没有正确同步\n\n修复方案:\n- upsert方法改用accountSequence作为唯一键\n- CDC消费者的handleUpdate使用accountSequence检查和更新\n- 更新时同时修复可能错误的userId\n- 新增existsByAccountSequence和updateKycStatusByAccountSequence方法\n\n影响范围:\n- admin-web用户管理页面现在能正确显示用户昵称和KYC状态\n- 新用户注册后数据能正确同步\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff backend/services/docker-compose.yml)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add backend/services/docker-compose.yml)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 添加uploads目录的volume持久化配置\n\n问题:admin-service重新部署后,上传的APK文件会丢失\n原因:主docker-compose.yml中admin-service未配置volume挂载,\n 导致容器重建时/app/uploads目录数据丢失\n\n修复:\n- 添加admin_uploads_data volume挂载到/app/uploads\n- 添加UPLOAD_DIR环境变量\n- 在volumes部分声明admin_uploads_data\n\n影响范围:仅影响admin-service的文件存储持久化\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/authorization/page.tsx)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/authorization/page.tsx)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-web\\): 优化授权页面错误提示,显示后端真实错误信息\n\n问题:创建授权失败时只显示\"Request failed with status code 400\"\n用户无法了解失败的真实原因(如用户未种树、授权冲突等)\n\n修复:\n- handleCreate和handleRevoke的catch块优先从err.response.data.message提取后端错误\n- 后端已有完善的错误提示如\"用户尚未认种任何树,无法授权\"\n- 前端现在能正确显示这些提示帮助管理员了解真实情况\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" checkout -- frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
|
||||
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 修复认种向导待办操作无法正确标记完成的问题\n\n问题:用户完成认种并签署合同后,ADOPTION_WIZARD待办操作没有被标记为完成,\n导致用户被卡在待办操作页面无法进入App。\n\n原因:原来的检查逻辑只检查是否有\"待签合同\",当用户已签署合同后,\npendingTasks为空,返回false,导致待办操作无法完成。\n\n修复方案:\n- 改为检查用户是否有已支付的认种订单(PAID/FUND_ALLOCATED状态)\n- 通过比较订单创建时间和待办操作创建时间来判断\n- 订单在待办操作之后创建 → 已完成\n- 订单在待办操作之前但相差不超过24小时 → 也认为已完成(兼容延迟)\n- 保留待签合同的备用检查逻辑\n\n影响范围:仅影响ADOPTION_WIZARD待办操作的完成检测\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -264,16 +264,23 @@ class ApiClient {
|
|||
}
|
||||
|
||||
/// GET 请求
|
||||
///
|
||||
/// [cancelToken] 用于取消请求
|
||||
/// [onReceiveProgress] 下载进度回调,参数为 (已接收字节数, 总字节数)
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
void Function(int, int)? onReceiveProgress,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
|
|
|
|||
|
|
@ -545,25 +545,79 @@ class ContractSigningService {
|
|||
}
|
||||
|
||||
/// 下载合同 PDF 文件
|
||||
Future<Uint8List> downloadContractPdf(String orderNo) async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 下载合同 PDF: $orderNo');
|
||||
///
|
||||
/// [orderNo] 订单号
|
||||
/// [onProgress] 下载进度回调,参数为 (已接收字节数, 总字节数)
|
||||
/// [cancelToken] 用于取消下载的 token
|
||||
///
|
||||
/// 特性:
|
||||
/// - 10 次自动重试
|
||||
/// - 300 秒超时
|
||||
/// - 支持进度回调
|
||||
/// - 支持取消下载
|
||||
Future<Uint8List> downloadContractPdf(
|
||||
String orderNo, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
const maxRetries = 10;
|
||||
const timeout = Duration(seconds: 300);
|
||||
|
||||
final response = await _apiClient.get(
|
||||
'/planting/contract-signing/tasks/$orderNo/pdf',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 下载合同 PDF: $orderNo (尝试 $attempt/$maxRetries)');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
debugPrint('[ContractSigningService] PDF 下载成功');
|
||||
return Uint8List.fromList(response.data);
|
||||
final response = await _apiClient.get(
|
||||
'/planting/contract-signing/tasks/$orderNo/pdf',
|
||||
options: Options(
|
||||
responseType: ResponseType.bytes,
|
||||
receiveTimeout: timeout,
|
||||
),
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onProgress,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
debugPrint('[ContractSigningService] PDF 下载成功,大小: ${response.data.length} bytes');
|
||||
return Uint8List.fromList(response.data);
|
||||
}
|
||||
|
||||
throw Exception('下载 PDF 失败: ${response.statusCode}');
|
||||
} on DioException catch (e) {
|
||||
debugPrint('[ContractSigningService] 下载 PDF 失败 (尝试 $attempt/$maxRetries): ${e.type} - ${e.message}');
|
||||
|
||||
// 如果是用户取消,直接抛出不重试
|
||||
if (e.type == DioExceptionType.cancel) {
|
||||
debugPrint('[ContractSigningService] 用户取消下载');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// 最后一次尝试失败,抛出异常
|
||||
if (attempt == maxRetries) {
|
||||
debugPrint('[ContractSigningService] 已达到最大重试次数,放弃下载');
|
||||
throw Exception('下载 PDF 失败,已重试 $maxRetries 次: ${e.message}');
|
||||
}
|
||||
|
||||
// 等待后重试,使用指数退避策略(2s, 4s, 6s...最大10s)
|
||||
final waitSeconds = (attempt * 2).clamp(2, 10);
|
||||
debugPrint('[ContractSigningService] 等待 ${waitSeconds}s 后重试...');
|
||||
await Future.delayed(Duration(seconds: waitSeconds));
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 下载 PDF 异常 (尝试 $attempt/$maxRetries): $e');
|
||||
|
||||
if (attempt == maxRetries) {
|
||||
debugPrint('[ContractSigningService] 已达到最大重试次数,放弃下载');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final waitSeconds = (attempt * 2).clamp(2, 10);
|
||||
debugPrint('[ContractSigningService] 等待 ${waitSeconds}s 后重试...');
|
||||
await Future.delayed(Duration(seconds: waitSeconds));
|
||||
}
|
||||
|
||||
throw Exception('下载 PDF 失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 下载 PDF 失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// 理论上不会到这里,但为了类型安全
|
||||
throw Exception('下载 PDF 失败');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
|
|||
/// 错误信息
|
||||
String? _errorMessage;
|
||||
|
||||
/// PDF 下载进度 (0-100)
|
||||
int _downloadProgress = 0;
|
||||
|
||||
/// PDF 下载重试次数
|
||||
int _downloadRetryCount = 0;
|
||||
|
||||
/// 倒计时定时器
|
||||
Timer? _countdownTimer;
|
||||
|
||||
|
|
@ -138,26 +144,45 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
|
|||
Future<void> _loadPdf() async {
|
||||
setState(() {
|
||||
_isPdfLoading = true;
|
||||
_downloadProgress = 0;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final service = ref.read(contractSigningServiceProvider);
|
||||
final pdfBytes = await service.downloadContractPdf(widget.orderNo);
|
||||
final pdfBytes = await service.downloadContractPdf(
|
||||
widget.orderNo,
|
||||
onProgress: (received, total) {
|
||||
if (total > 0 && mounted) {
|
||||
final progress = ((received / total) * 100).round();
|
||||
setState(() {
|
||||
_downloadProgress = progress;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 保存到临时文件
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempFile = File('${tempDir.path}/contract_${widget.orderNo}.pdf');
|
||||
await tempFile.writeAsBytes(pdfBytes);
|
||||
|
||||
setState(() {
|
||||
_pdfPath = tempFile.path;
|
||||
_isPdfLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pdfPath = tempFile.path;
|
||||
_isPdfLoading = false;
|
||||
_downloadProgress = 100;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isPdfLoading = false;
|
||||
_errorMessage = '加载合同 PDF 失败: $e';
|
||||
});
|
||||
debugPrint('[ContractSigningPage] PDF 加载失败: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPdfLoading = false;
|
||||
_downloadRetryCount++;
|
||||
_errorMessage = '加载合同失败,请点击重试';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -575,26 +600,54 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
|
|||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
// 判断是任务加载错误还是 PDF 下载错误
|
||||
final isPdfError = _task != null && _pdfPath == null;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isPdfError ? Icons.cloud_download_outlined : Icons.error_outline,
|
||||
color: isPdfError ? const Color(0xFFD4AF37) : Colors.red,
|
||||
size: 56,
|
||||
),
|
||||
child: const Text('重试', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: isPdfError ? const Color(0xFF666666) : Colors.red,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: isPdfError ? _loadPdf : _loadTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
||||
),
|
||||
icon: const Icon(Icons.refresh, color: Colors.white),
|
||||
label: const Text(
|
||||
'重试',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
),
|
||||
if (isPdfError && _downloadRetryCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'已自动重试 $_downloadRetryCount 次',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -610,19 +663,54 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
|
|||
|
||||
// PDF 加载中
|
||||
if (_isPdfLoading || _pdfPath == null) {
|
||||
return const Center(
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Color(0xFFD4AF37)),
|
||||
SizedBox(height: 16),
|
||||
// 进度圆环
|
||||
SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: _downloadProgress > 0 ? _downloadProgress / 100 : null,
|
||||
color: const Color(0xFFD4AF37),
|
||||
strokeWidth: 6,
|
||||
backgroundColor: const Color(0xFFE0E0E0),
|
||||
),
|
||||
if (_downloadProgress > 0)
|
||||
Text(
|
||||
'$_downloadProgress%',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'正在加载合同...',
|
||||
style: TextStyle(
|
||||
_downloadProgress > 0 ? '正在下载合同 $_downloadProgress%' : '正在加载合同...',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
if (_downloadRetryCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'正在重试 (第 $_downloadRetryCount 次)',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue