f3cfdcadca
根因: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
421 lines
15 KiB
Dart
421 lines
15 KiB
Dart
// screens/settings/purchase_card.dart — App 内在线购买/续费授权(内联卡片)。
|
||
// 流程:套餐选择(标准/首月特惠/高级,非特惠可切月付/年付)→ POST /license/purchase
|
||
// 下单 → 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本卡片轮询
|
||
// GET /license/purchase/:otn,webhook 续期到账后自动刷新授权并展示新到期日。
|
||
// 价格仅展示,实付以 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]},');
|
||
}
|