wechat_qr_dialog.dart 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. /// 微信 Native 扫码支付弹窗(Web / 桌面端)
  2. ///
  3. /// 展示后端统一下单返回的 code_url 二维码,并轮询支付状态:
  4. /// - 未支付:持续轮询(每 3s)
  5. /// - 支付成功:显示对勾并自动关闭
  6. /// - 已关闭/失败:显示结果并关闭
  7. ///
  8. /// Mock 模式下 code_url 为占位串(不可真实扫码),提供「模拟支付成功」按钮
  9. /// 触发后端 simulate 接口,使演示可在无真实商户密钥时端到端跑通。
  10. library;
  11. import 'dart:async';
  12. import 'package:flutter/material.dart';
  13. import 'package:qr_flutter/qr_flutter.dart';
  14. import '../service/api_service.dart';
  15. import '../utils/toast.dart';
  16. /// 微信扫码支付弹窗
  17. class WechatQrDialog extends StatefulWidget {
  18. const WechatQrDialog({
  19. super.key,
  20. required this.codeUrl,
  21. required this.paymentId,
  22. this.onClose,
  23. });
  24. /// 后端统一下单返回的二维码内容(code_url)
  25. final String codeUrl;
  26. /// 支付单号(用于轮询状态)
  27. final String paymentId;
  28. /// 关闭回调(可选,支付结果确认时触发)
  29. final void Function(bool success)? onClose;
  30. @override
  31. State<WechatQrDialog> createState() => _WechatQrDialogState();
  32. }
  33. class _WechatQrDialogState extends State<WechatQrDialog> {
  34. Timer? _timer;
  35. bool _paid = false;
  36. bool _simulating = false;
  37. bool _closed = false;
  38. @override
  39. void initState() {
  40. super.initState();
  41. _startPolling();
  42. }
  43. @override
  44. void dispose() {
  45. _timer?.cancel();
  46. super.dispose();
  47. }
  48. void _startPolling() {
  49. _timer = Timer.periodic(const Duration(seconds: 3), (_) async {
  50. try {
  51. final status = await APIServer().queryPaymentStatus(widget.paymentId);
  52. if (!mounted) return;
  53. if (status.success) {
  54. _finish(true);
  55. } else if ((status.note ?? '').contains('关闭')) {
  56. _finish(false);
  57. }
  58. } catch (e) {
  59. // 轮询失败静默重试(后端未就绪等情况)
  60. }
  61. });
  62. }
  63. /// 支付结果确认:停轮询、更新 UI、通知外部并自动关闭
  64. void _finish(bool success) {
  65. _timer?.cancel();
  66. if (_closed) return;
  67. _closed = true;
  68. if (!mounted) return;
  69. setState(() => _paid = success);
  70. if (success) {
  71. showSuccessMessage('支付成功');
  72. widget.onClose?.call(true);
  73. Future.delayed(const Duration(milliseconds: 600), () {
  74. if (mounted) Navigator.of(context).pop();
  75. });
  76. } else {
  77. showErrorMessage('订单已关闭,请重新下单');
  78. widget.onClose?.call(false);
  79. Future.delayed(const Duration(milliseconds: 600), () {
  80. if (mounted) Navigator.of(context).pop();
  81. });
  82. }
  83. }
  84. /// Mock 模式:模拟支付成功(后端会幂等标记 PAID,轮询自动收尾)
  85. Future<void> _simulate() async {
  86. if (_simulating) return;
  87. setState(() => _simulating = true);
  88. try {
  89. await APIServer().simulatePaymentSuccess(widget.paymentId);
  90. // 轮询会在下一次 tick 感知到 PAID;这里主动查一次加速
  91. await Future.delayed(const Duration(milliseconds: 500));
  92. final status = await APIServer().queryPaymentStatus(widget.paymentId);
  93. if (mounted && status.success) _finish(true);
  94. } on Exception catch (e) {
  95. if (mounted) {
  96. setState(() => _simulating = false);
  97. showErrorMessage(APIServer().resolveError(e));
  98. }
  99. }
  100. }
  101. @override
  102. Widget build(BuildContext context) {
  103. return SizedBox(
  104. width: 420,
  105. child: Column(
  106. mainAxisSize: MainAxisSize.min,
  107. children: [
  108. const Text('请使用微信「扫一扫」完成支付', style: TextStyle(fontSize: 16)),
  109. const SizedBox(height: 20),
  110. if (_paid)
  111. const Icon(Icons.check_circle, color: Colors.green, size: 80)
  112. else
  113. Container(
  114. padding: const EdgeInsets.all(16),
  115. color: Colors.white,
  116. child: QrImageView(
  117. data: widget.codeUrl,
  118. version: QrVersions.auto,
  119. size: 220,
  120. backgroundColor: Colors.white,
  121. ),
  122. ),
  123. const SizedBox(height: 12),
  124. Text(
  125. _paid ? '支付成功' : '支付单号:${widget.paymentId}',
  126. style: const TextStyle(color: Colors.grey, fontSize: 12),
  127. ),
  128. if (!_paid) ...[
  129. const SizedBox(height: 12),
  130. // Mock 演示:模拟支付成功
  131. OutlinedButton.icon(
  132. onPressed: _simulating ? null : _simulate,
  133. icon: const Icon(Icons.flash_on, size: 18),
  134. label: Text(_simulating ? '处理中...' : '模拟支付成功(Mock)'),
  135. ),
  136. ],
  137. const SizedBox(height: 8),
  138. TextButton(
  139. onPressed: () => Navigator.of(context).pop(),
  140. child: const Text('关闭'),
  141. ),
  142. ],
  143. ),
  144. );
  145. }
  146. }