feat(client): 购买卡按 render_type 分发——redirect 外跳/qr 内嵌二维码/重选取消订单
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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? ?? '取消订单失败,请稍后重试',
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<div class="seg">${items.map(([k,label])=>`<button class="${cur===k?'on':''}" onclick="${fn}('${k}')">${label}</button>`).join('')}</div>`; }
|
||||
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=`<div style="display:flex;align-items:center;gap:12px"><div class="spin"></div><div style="flex:1;font-size:var(--fs-sm);color:var(--text)">已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。</div></div>
|
||||
const isQr=buy.renderType==='qr';
|
||||
// 演示开关:redirect/qr 两态切换,仅原型评审对照用(详见 buy 对象注释)
|
||||
const modeSeg=`<div class="seg" style="margin-bottom:12px">
|
||||
<button class="${!isQr?'on':''}" onclick="buy.renderType='redirect';drawBuy()">redirect 态</button>
|
||||
<button class="${isQr?'on':''}" onclick="buy.renderType='qr';drawBuy()">qr 态(预留,pay 侧未实现)</button>
|
||||
</div>`;
|
||||
const topRow=isQr
|
||||
? `<div style="display:flex;align-items:center;gap:12px"><div class="spin"></div><div style="flex:1;font-size:var(--fs-sm);color:var(--text)">打开支付宝扫码支付,完成后本卡片自动确认到账。</div></div>
|
||||
<div class="qr-ph">二维码占位<br>180×180</div>`
|
||||
: `<div style="display:flex;align-items:center;gap:12px"><div class="spin"></div><div style="flex:1;font-size:var(--fs-sm);color:var(--text)">已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。</div></div>`;
|
||||
el.innerHTML=modeSeg+topRow+`
|
||||
<div class="obox"><div style="font-size:var(--fs-xs);color:var(--muted)">订单号</div><div style="font-size:var(--fs-sm);font-family:var(--font-mono);margin-top:4px">JIU20260710160001</div><div style="font-size:var(--fs-sm);color:var(--text);margin-top:8px">金额 ¥${fmt(p)}</div></div>
|
||||
<div class="fine" style="margin-top:10px">离开本页不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。</div>
|
||||
<div class="acts"><div class="btn ghost" onclick="buyBack()">重新选择</div><div class="btn primary" onclick="buyDone()">我已完成支付</div></div>`;
|
||||
|
||||
@@ -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 `<div class="seg">${items.map(([k,label])=>`<button class="${cur===k?'on':''}" onclick="${fn}('${k}')">${label}</button>`).join('')}</div>`; }
|
||||
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=`<div style="display:flex;align-items:center;gap:12px"><div class="spin"></div><div style="flex:1;font-size:var(--fs-sm);color:var(--text)">已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。</div></div>
|
||||
const isQr=buy.renderType==='qr';
|
||||
// 演示开关:redirect/qr 两态切换,仅原型评审对照用(详见 buy 对象注释)
|
||||
const modeSeg=`<div class="seg" style="margin-bottom:12px">
|
||||
<button class="${!isQr?'on':''}" onclick="buy.renderType='redirect';drawBuy()">redirect 态</button>
|
||||
<button class="${isQr?'on':''}" onclick="buy.renderType='qr';drawBuy()">qr 态(预留,pay 侧未实现)</button>
|
||||
</div>`;
|
||||
const topRow=isQr
|
||||
? `<div style="display:flex;align-items:center;gap:12px"><div class="spin"></div><div style="flex:1;font-size:var(--fs-sm);color:var(--text)">打开支付宝扫码支付,完成后本卡片自动确认到账。</div></div>
|
||||
<div class="qr-ph">二维码占位<br>180×180</div>`
|
||||
: `<div style="display:flex;align-items:center;gap:12px"><div class="spin"></div><div style="flex:1;font-size:var(--fs-sm);color:var(--text)">已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。</div></div>`;
|
||||
el.innerHTML=modeSeg+topRow+`
|
||||
<div class="obox"><div style="font-size:var(--fs-xs);color:var(--muted)">订单号</div><div style="font-size:var(--fs-sm);font-family:var(--font-mono);margin-top:4px">JIU20260710160001</div><div style="font-size:var(--fs-sm);color:var(--text);margin-top:8px">金额 ¥${fmt(p)}</div></div>
|
||||
<div class="fine" style="margin-top:10px">离开本页不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。</div>
|
||||
<div class="acts"><div class="btn ghost" onclick="buyBack()">重新选择</div><div class="btn primary" onclick="buyDone()">我已完成支付</div></div>`;
|
||||
|
||||
Reference in New Issue
Block a user