Files
jiu/client/lib/screens/settings/purchase_card.dart
T
wangjia f3cfdcadca feat: 购买下单透传终端类型,手机端收银台拉起支付宝 App(契约 v1.1.0)
根因:pay 判手机/PC 用下单请求 UA,而下单由 jiu 后端 Go client 发出,
恒被判 PC 扫码页——手机用户拿不到 wap 拉起页。

- backend:/license/purchase 请求体加可选 client_type(oneof=pc mobile),
  缺省按本请求 UA 兜底(官网浏览器购买自动受益);CreatePurchase 透传给 pay;
  单测断言透传 + 既有用例补参
- client:createPurchase 支持 clientType;PurchaseCard 按平台声明
  (iOS/Android=mobile,桌面=pc,Web 不传走 UA),kIsWeb 先于 dart:io;
  launchUrl 外部浏览器不变,wap 收银台自动拉起支付宝
- 配套:pay-contract v1.1.0(744725b)+ pay 侧 resolveIsMobile(dbdadd1)已各自提交

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-04 10:31:49 +08:00

421 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// screens/settings/purchase_card.dart — App 内在线购买/续费授权(内联卡片)。
// 流程:套餐选择(标准/首月特惠/高级,非特惠可切月付/年付)→ POST /license/purchase
// 下单 → 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本卡片轮询
// GET /license/purchase/:otnwebhook 续期到账后自动刷新授权并展示新到期日。
// 价格仅展示,实付以 pay 侧套餐表为准(下单响应回传 amount)。
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
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:url_launcher/url_launcher.dart';
import '../../core/config/license_plans.dart';
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 '../../providers/license_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
enum _Phase { pick, waiting, success }
/// 在线购买/续费卡片内容(由设置页包进 _SettingsCard,仅管理员可见)。
class PurchaseCard extends ConsumerStatefulWidget {
const PurchaseCard({super.key});
@override
ConsumerState<PurchaseCard> createState() => _PurchaseCardState();
}
class _PurchaseCardState extends ConsumerState<PurchaseCard> {
_Phase _phase = _Phase.pick;
int _tabIdx = 0; // 0=首月特惠 1=标准版 2=高级版
int _cycleIdx = 0; // 0=年付 1=月付
bool _submitting = false;
bool _checking = false;
bool _promoUsed = false; // 首月特惠每店限一次,已享用则置灰(服务端仍强校验)
PurchaseOrder? _order;
DateTime? _newExpiresAt;
Timer? _pollTimer;
bool get _isPromo => _tabIdx == 0;
LicensePlanGroup get _group =>
_tabIdx == 1 ? LicensePlans.standard : LicensePlans.pro;
LicensePlan get _plan => _isPromo
? LicensePlans.promo
: (_cycleIdx == 0 ? _group.annual : _group.monthly);
String get _cycleLabel => _cycleIdx == 0 ? '年付' : '月付';
@override
void initState() {
super.initState();
ref.read(licenseRepositoryProvider).promoUsed().then((used) {
// 已享受过特惠:置灰并把默认选中切到「高级版 · 年付」
if (mounted && used) {
setState(() {
_promoUsed = true;
if (_phase == _Phase.pick && _isPromo) {
_tabIdx = 2;
_cycleIdx = 0;
}
});
}
});
}
@override
void dispose() {
_pollTimer?.cancel();
super.dispose();
}
/// 终端类型(契约 v1.1.0):手机 App 显式声明 mobile 让 pay 出「手机网站支付」
/// 收银台(H5 拉起支付宝 App);桌面 App 声明 pc(扫码页);Web 端不传,
/// 后端按浏览器真实 UA 判断。kIsWeb 判断先于 dart:io(项目规则)。
String? get _clientType {
if (kIsWeb) return null;
return (Platform.isIOS || Platform.isAndroid) ? 'mobile' : 'pc';
}
Future<void> _submit() async {
setState(() => _submitting = true);
try {
final order = await ref
.read(licenseRepositoryProvider)
.createPurchase(_plan.bizCode, clientType: _clientType);
// 外部浏览器打开收银台:桌面端进网页支付,移动端由系统浏览器打开
// wap 收银台自动拉起支付宝 App
await launchUrl(Uri.parse(order.payUrl),
mode: LaunchMode.externalApplication);
if (!mounted) return;
setState(() {
_order = order;
_phase = _Phase.waiting;
_submitting = false;
});
_pollTimer = Timer.periodic(const Duration(seconds: 3), (_) => _poll());
} catch (e) {
if (!mounted) return;
setState(() => _submitting = false);
showDsToast(context, '下单失败:$e', bg: context.tokens.danger);
}
}
Future<void> _poll({bool manual = false}) async {
final order = _order;
if (order == null || _checking) return;
_checking = true;
try {
final st = await ref
.read(licenseRepositoryProvider)
.purchaseStatus(order.outTradeNo);
if (!mounted) return;
if (st.isPaid) {
_pollTimer?.cancel();
ref.invalidate(licenseProvider);
setState(() {
_newExpiresAt = st.expiresAt;
_phase = _Phase.success;
});
} else if (manual) {
showDsToast(context, '尚未确认到账,支付完成后通常数秒内到账');
}
} catch (e) {
if (mounted && manual) {
showDsToast(context, '查询失败:$e', bg: context.tokens.danger);
}
} finally {
_checking = false;
}
}
/// 回到套餐选择(仅停止本地轮询,不影响已支付订单到账)。
void _backToPick() {
_pollTimer?.cancel();
setState(() {
_order = null;
_newExpiresAt = null;
_phase = _Phase.pick;
});
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
// 与授权卡兑换区一致收窄内容宽度,避免宽屏卡片内元素拉伸过长
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: switch (_phase) {
_Phase.pick => _buildPick(t),
_Phase.waiting => _buildWaiting(t),
_Phase.success => _buildSuccess(t),
},
);
}
Widget _buildPick(AppTokens t) {
final promoGrayed = _isPromo && _promoUsed;
final feats = _isPromo ? LicensePlans.promoFeats : _group.feats;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(spacing: 10, runSpacing: 8, children: [
DsSeg(
items: [
_promoUsed ? '首月特惠(已享受)' : '🔥 首月特惠 ¥1',
'标准版',
'高级版',
],
index: _tabIdx,
onChanged: (i) => setState(() => _tabIdx = i),
),
if (!_isPromo)
DsSeg(
items: const ['年付 · 省 2 个月', '月付'],
index: _cycleIdx,
onChanged: (i) => setState(() => _cycleIdx = i),
),
]),
const SizedBox(height: 14),
if (_isPromo) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: promoGrayed ? t.bg : t.warnBg,
border: Border.all(color: promoGrayed ? t.border : t.warn),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(promoGrayed ? '本店已享受过首月特惠' : '新店专享 · 仅 ¥1 体验 30 天',
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w700,
color: promoGrayed ? t.faint : t.warn)),
const SizedBox(height: 3),
Text(
promoGrayed
? '首月特惠每个门店限购一次,可选择标准版或高级版继续续费。'
: '解锁标准版全部功能,原价 ¥299,每个门店限购一次——试过才知道多省心。',
style: TextStyle(
fontSize: AppDims.fsSm,
color: promoGrayed ? t.faint : t.text)),
],
),
),
const SizedBox(height: 10),
],
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final f in feats)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: [
Icon(LucideIcons.check,
size: 14, color: promoGrayed ? t.faint : t.success),
const SizedBox(width: 8),
Expanded(
child: Text(f,
style: TextStyle(
fontSize: AppDims.fsSm,
color: promoGrayed ? t.faint : t.text))),
]),
),
],
),
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
_isPromo
? '首月特惠 · ${_plan.days} 天授权'
: '${_group.name} · $_cycleLabel · ${_plan.days} 天授权',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
if (_isPromo) ...[
Text('¥${_fmt(LicensePlans.promoOriginalPrice)}',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.faint,
decoration: TextDecoration.lineThrough,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
const SizedBox(width: 8),
],
// 年付划线展示月付×12 原价,直观看到省 2 个月
if (!_isPromo && _cycleIdx == 0) ...[
Text('¥${_fmt(_group.monthly.price * 12)}',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.faint,
decoration: TextDecoration.lineThrough,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
const SizedBox(width: 8),
],
Text('¥${_fmt(_plan.price)}',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: promoGrayed
? t.faint
: _isPromo
? t.warn
: t.heading,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
],
),
if (!_isPromo && _cycleIdx == 0) ...[
const SizedBox(height: 6),
Text(
'月付 ¥${_fmt(_group.monthly.price)} × 12 个月 = ¥${_fmt(_group.monthly.price * 12)}'
'年付立省 ¥${_fmt(_group.monthly.price * 12 - _group.annual.price)},相当于免费用 2 个月',
style: TextStyle(fontSize: AppDims.fsXs, color: t.success)),
],
const SizedBox(height: 6),
Text('到期时间自动叠加,未到期续费不吃亏 · 支付宝支付',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: DsButton(
_submitting
? '正在创建订单…'
: _isPromo && _promoUsed
? '本店已享受过'
: '提交订单 · ¥${_fmt(_plan.price)}',
variant: DsBtnVariant.primary,
onPressed:
_submitting || (_isPromo && _promoUsed) ? null : _submit),
),
],
);
}
Widget _buildWaiting(AppTokens t) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: t.primary),
),
const SizedBox(width: 12),
Expanded(
child: Text('已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。',
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
),
]),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.borderSubtle),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('订单号',
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(height: 4),
Text(_order?.outTradeNo ?? '',
style: const TextStyle(
fontSize: AppDims.fsSm,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
const SizedBox(height: 8),
Text('金额 ¥${_order?.amount ?? ''}',
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
],
),
),
const SizedBox(height: 10),
Text('离开本页不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DsButton('重新选择', onPressed: _backToPick),
const SizedBox(width: 10),
DsButton('我已完成支付',
variant: DsBtnVariant.primary,
onPressed: () => _poll(manual: true)),
],
),
],
);
}
Widget _buildSuccess(AppTokens t) {
final expires = _newExpiresAt != null
? DateFormat('yyyy-MM-dd').format(_newExpiresAt!)
: null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: t.okSoft,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Icon(LucideIcons.check, size: 20, color: t.success),
),
const SizedBox(width: 12),
Expanded(
child: Text(expires != null ? '门店授权已续期至 $expires' : '门店授权已续期。',
style: TextStyle(
fontSize: AppDims.fsTitle,
fontWeight: FontWeight.w600,
color: t.heading)),
),
]),
const SizedBox(height: 10),
Text('订单号 ${_order?.outTradeNo ?? ''}',
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: DsButton('完成', onPressed: _backToPick),
),
],
);
}
static String _fmt(int n) => n
.toString()
.replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},');
}