feat(client): 购买卡按 render_type 分发——redirect 外跳/qr 内嵌二维码/重选取消订单

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 23:46:19 +08:00
parent 179051a8fa
commit 9fe1e2766f
6 changed files with 101 additions and 10 deletions
@@ -84,6 +84,9 @@ class LicenseRepository {
return data?['canceled'] as bool? ?? false;
} on DioException catch (e) {
final status = e.response?.statusCode;
// 409 分支防御性保留:本后端实现里非 pending 单取消返回 200 +
// canceled:false(业务性失败,非 HTTP 错误),故 409 实际永不触发;
// 保留兜底以防后端未来改走标准 HTTP 语义时前端仍能优雅降级。
if (status == 404 || status == 409) return false;
throw AppException(
e.response?.data?['error'] as String? ?? '取消订单失败,请稍后重试',
+54 -6
View File
@@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/config/license_plans.dart';
@@ -18,6 +19,7 @@ import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_fonts.dart';
import '../../core/theme/app_tokens.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/money.dart';
import '../../providers/license_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
@@ -89,10 +91,23 @@ class _PurchaseCardState extends ConsumerState<PurchaseCard> {
final order = await ref
.read(licenseRepositoryProvider)
.createPurchase(_plan.bizCode, clientType: _clientType);
// 外部浏览器打开收银台:桌面端进网页支付,移动端由系统浏览器打开
// wap 收银台自动拉起支付宝 App
await launchUrl(Uri.parse(order.payUrl),
mode: LaunchMode.externalApplication);
// pay v2 收银台会话按 render_type 分发:redirect 外跳外部浏览器(桌面进
// 网页支付,移动端 wap 收银台自动拉起支付宝 App);qr 不外跳,本卡片
// waiting 态内嵌二维码(pay 侧当前未实现该分支,向前预留);未知类型
// 视为客户端版本落后于协议,提示升级并复位,不进入 waiting 态。
switch (order.renderType) {
case 'redirect':
await launchUrl(Uri.parse(order.redirectUrl),
mode: LaunchMode.externalApplication);
case 'qr':
break;
default:
if (!mounted) return;
setState(() => _submitting = false);
showDsToast(context, '当前版本暂不支持该支付方式,请升级应用',
bg: context.tokens.danger);
return;
}
if (!mounted) return;
setState(() {
_order = order;
@@ -141,8 +156,17 @@ class _PurchaseCardState extends ConsumerState<PurchaseCard> {
}
/// 回到套餐选择(仅停止本地轮询,不影响已支付订单到账)。
/// waiting 态下的订单仍是 pending(重新选择/放弃本单),fire-and-forget
/// 取消,防止 pending 单堆积;取消失败静默——服务端 reconcile 任务兜底
/// 清理超时单。success 态点「完成」不带 pending 单,不触发取消。
void _backToPick() {
_pollTimer?.cancel();
final otn = _order?.outTradeNo;
if (_phase == _Phase.waiting && otn != null && otn.isNotEmpty) {
ref.read(licenseRepositoryProvider).cancelPurchase(otn).catchError((_) {
return false;
});
}
setState(() {
_order = null;
_newExpiresAt = null;
@@ -320,6 +344,9 @@ class _PurchaseCardState extends ConsumerState<PurchaseCard> {
}
Widget _buildWaiting(AppTokens t) {
final isQr = _order?.renderType == 'qr';
final amountMinor = _order?.amountMinor ?? 0;
final amountText = amountMinor > 0 ? '¥${yuanFromMinor(amountMinor)}' : '';
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -332,10 +359,31 @@ class _PurchaseCardState extends ConsumerState<PurchaseCard> {
),
const SizedBox(width: 12),
Expanded(
child: Text('已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。',
child: Text(
isQr
? '打开支付宝扫码支付,完成后本卡片自动确认到账。'
: '已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。',
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
),
]),
if (isQr) ...[
const SizedBox(height: 12),
Center(
child: Container(
width: 180,
height: 180,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: (_order?.qrContent ?? '').isEmpty
? Icon(LucideIcons.qrCode, size: 48, color: t.faint)
: QrImageView(data: _order!.qrContent, size: 160),
),
),
],
const SizedBox(height: 14),
Container(
width: double.infinity,
@@ -357,7 +405,7 @@ class _PurchaseCardState extends ConsumerState<PurchaseCard> {
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
const SizedBox(height: 8),
Text('金额 ¥${_order?.amount ?? ''}',
Text('金额 $amountText',
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
],
),
+8
View File
@@ -669,6 +669,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record_use:
dependency: transitive
description:
+2
View File
@@ -32,6 +32,8 @@ dependencies:
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
charset_converter: ^2.1.0
lucide_icons_flutter: ^3.1.14+2
# 购买卡 render_type=qr 内嵌二维码(纯 Dart,全平台安全,无原生依赖)
qr_flutter: ^4.1.0
# Web 端去 URL # 前缀(usePathUrlStrategy),Flutter SDK 内置包
flutter_web_plugins:
sdk: flutter