From 9fe1e2766fdbf9d7c35f9097496cd5a3345943ad Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 23:46:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E8=B4=AD=E4=B9=B0=E5=8D=A1?= =?UTF-8?q?=E6=8C=89=20render=5Ftype=20=E5=88=86=E5=8F=91=E2=80=94?= =?UTF-8?q?=E2=80=94redirect=20=E5=A4=96=E8=B7=B3/qr=20=E5=86=85=E5=B5=8C?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81/=E9=87=8D=E9=80=89=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../lib/repositories/license_repository.dart | 3 + .../lib/screens/settings/purchase_card.dart | 60 +++++++++++++++++-- client/pubspec.lock | 8 +++ client/pubspec.yaml | 2 + design/prototype/screens/license.html | 19 +++++- design/prototype/screens/m-license.html | 19 +++++- 6 files changed, 101 insertions(+), 10 deletions(-) diff --git a/client/lib/repositories/license_repository.dart b/client/lib/repositories/license_repository.dart index 0f2ee33..1f174a2 100644 --- a/client/lib/repositories/license_repository.dart +++ b/client/lib/repositories/license_repository.dart @@ -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? ?? '取消订单失败,请稍后重试', diff --git a/client/lib/screens/settings/purchase_card.dart b/client/lib/screens/settings/purchase_card.dart index 5e4fdd0..51c86a3 100644 --- a/client/lib/screens/settings/purchase_card.dart +++ b/client/lib/screens/settings/purchase_card.dart @@ -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 { 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 { } /// 回到套餐选择(仅停止本地轮询,不影响已支付订单到账)。 + /// 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 { } 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 { ), 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 { fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), const SizedBox(height: 8), - Text('金额 ¥${_order?.amount ?? '—'}', + Text('金额 $amountText', style: TextStyle(fontSize: AppDims.fsSm, color: t.text)), ], ), diff --git a/client/pubspec.lock b/client/pubspec.lock index 982149f..769b263 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -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: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index f1687a7..9c463b4 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -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 diff --git a/design/prototype/screens/license.html b/design/prototype/screens/license.html index cfd7830..71bc98d 100644 --- a/design/prototype/screens/license.html +++ b/design/prototype/screens/license.html @@ -52,6 +52,8 @@ .buy .fine{font-size:var(--fs-xs); color:var(--faint); margin-top:6px;} .buy .acts{display:flex; justify-content:flex-end; gap:10px; margin-top:12px;} .buy .obox{padding:12px; background:var(--bg); border:1px solid var(--border-subtle); border-radius:var(--r-md); margin-top:14px;} + /* qr 态二维码占位(render_type=qr,pay 侧预留未实现,仅演示两态)—— 待提升至 atoms.css */ + .buy .qr-ph{width:180px; height:180px; margin:12px auto 0; border:1px solid var(--border); border-radius:var(--r-md); background:var(--bg); display:flex; align-items:center; justify-content:center; color:var(--faint); font-size:var(--fs-xs); text-align:center; box-sizing:border-box;} .buy .spin{width:18px; height:18px; border-radius:50%; border:2px solid var(--border); border-top-color:var(--primary); animation:buy-spin .8s linear infinite; flex:none;} @keyframes buy-spin{to{transform:rotate(360deg)}} .buy .okic{width:40px; height:40px; border-radius:var(--r-md); background:var(--ok-soft); color:var(--success); display:flex; align-items:center; justify-content:center; flex:none;} @@ -195,7 +197,10 @@ const PLANS={ standard:{name:'标准版',monthly:299,annual:2999,feats:['单门店 · 单仓库','2 台客户端同时使用','1,000 张商品图片分享']}, pro:{name:'高级版',monthly:599,annual:5999,feats:['单门店 · 多仓库','5 台客户端同时使用','10,000 张商品图片分享','免费 AI 周度 / 月度商业数据分析']}, }; -const buy={tab:'promo',cycle:'annual',phase:'pick',promoUsed:false}; +// renderType:pay v2 收银台会话形态(redirect|qr)。真机 alipay 目前只返回 +// redirect,qr 是 pay 侧未实现的预留分支——演示开关仅供原型评审对照两态 UI, +// 非真实产品控件(真实按后端 render_type 自动分发,见 purchase_card.dart)。 +const buy={tab:'promo',cycle:'annual',phase:'pick',promoUsed:false,renderType:'redirect'}; function fmt(n){ return n.toLocaleString(); } function segHtml(items,cur,fn){ return `
${items.map(([k,label])=>``).join('')}
`; } function buyTab(k){ if(k==='promo'&&buy.promoUsed){ toast('本店已享受过首月特惠'); } buy.tab=k; drawBuy(); } @@ -208,7 +213,17 @@ function drawBuy(){ const el=document.getElementById('buyCard'); if(buy.phase==='waiting'){ const p=buy.tab==='promo'?PLANS.promo.price:(buy.cycle==='annual'?PLANS[buy.tab].annual:PLANS[buy.tab].monthly); - el.innerHTML=`
已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。
+ const isQr=buy.renderType==='qr'; + // 演示开关:redirect/qr 两态切换,仅原型评审对照用(详见 buy 对象注释) + const modeSeg=`
+ + +
`; + const topRow=isQr + ? `
打开支付宝扫码支付,完成后本卡片自动确认到账。
+
二维码占位
180×180
` + : `
已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。
`; + el.innerHTML=modeSeg+topRow+`
订单号
JIU20260710160001
金额 ¥${fmt(p)}
离开本页不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。
重新选择
我已完成支付
`; diff --git a/design/prototype/screens/m-license.html b/design/prototype/screens/m-license.html index 2375fcd..1463bc9 100644 --- a/design/prototype/screens/m-license.html +++ b/design/prototype/screens/m-license.html @@ -44,6 +44,8 @@ body{margin:0; font-family:var(--font); color:var(--text); background:var(--page .buy .fine{font-size:var(--fs-xs); color:var(--faint); margin-top:6px;} .buy .acts{display:flex; justify-content:flex-end; gap:10px; margin-top:12px;} .buy .obox{padding:12px; background:var(--bg); border:1px solid var(--border-subtle); border-radius:var(--r-md); margin-top:14px;} +/* qr 态二维码占位(render_type=qr,pay 侧预留未实现,仅演示两态)—— 待提升至 atoms.css */ +.buy .qr-ph{width:180px; height:180px; margin:12px auto 0; border:1px solid var(--border); border-radius:var(--r-md); background:var(--bg); display:flex; align-items:center; justify-content:center; color:var(--faint); font-size:var(--fs-xs); text-align:center; box-sizing:border-box;} .buy .spin{width:18px; height:18px; border-radius:50%; border:2px solid var(--border); border-top-color:var(--primary); animation:buy-spin .8s linear infinite; flex:none;} @keyframes buy-spin{to{transform:rotate(360deg)}} .buy .okic{width:40px; height:40px; border-radius:var(--r-md); background:var(--ok-soft); color:var(--success); display:flex; align-items:center; justify-content:center; flex:none;} @@ -169,7 +171,10 @@ const PLANS={ standard:{name:'标准版',monthly:299,annual:2999,feats:['单门店 · 单仓库','2 台客户端同时使用','1,000 张商品图片分享']}, pro:{name:'高级版',monthly:599,annual:5999,feats:['单门店 · 多仓库','5 台客户端同时使用','10,000 张商品图片分享','免费 AI 周度 / 月度商业数据分析']}, }; -const buy={tab:'promo',cycle:'annual',phase:'pick',promoUsed:false}; +// renderType:pay v2 收银台会话形态(redirect|qr)。真机 alipay 目前只返回 +// redirect,qr 是 pay 侧未实现的预留分支——演示开关仅供原型评审对照两态 UI, +// 非真实产品控件(真实按后端 render_type 自动分发,见 purchase_card.dart)。 +const buy={tab:'promo',cycle:'annual',phase:'pick',promoUsed:false,renderType:'redirect'}; function fmt(n){ return n.toLocaleString(); } function segHtml(items,cur,fn){ return `
${items.map(([k,label])=>``).join('')}
`; } function buyTab(k){ if(k==='promo'&&buy.promoUsed){ mToast('本店已享受过首月特惠'); } buy.tab=k; drawBuy(); } @@ -182,7 +187,17 @@ function drawBuy(){ const el=document.getElementById('buyCard'); if(buy.phase==='waiting'){ const p=buy.tab==='promo'?PLANS.promo.price:(buy.cycle==='annual'?PLANS[buy.tab].annual:PLANS[buy.tab].monthly); - el.innerHTML=`
已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。
+ const isQr=buy.renderType==='qr'; + // 演示开关:redirect/qr 两态切换,仅原型评审对照用(详见 buy 对象注释) + const modeSeg=`
+ + +
`; + const topRow=isQr + ? `
打开支付宝扫码支付,完成后本卡片自动确认到账。
+
二维码占位
180×180
` + : `
已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。
`; + el.innerHTML=modeSeg+topRow+`
订单号
JIU20260710160001
金额 ¥${fmt(p)}
离开本页不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。
重新选择
我已完成支付
`;