b3c9d98eae
- 后端:payPlans 新增 promo_first_month(¥1/30 天/标准版权益);下单强校验每店限购一次(仅 paid 计数,弃单可重下),已享用返回 409;新增 GET /license/promo-status 供前端置灰 - 官网:checkout 套餐段第二位插「🔥 首月特惠 ¥1」+ 推广横幅 + 划线原价 ¥299;首页价格区第二位插特惠卡(5 列);已享用自动置灰 - App:购买弹窗套餐段改三档(标准/特惠/高级),特惠选中显示推广横幅与划线价,已享用置灰并禁用提交 - 实付金额以 pay 侧为准,pay 需 seed promo_first_month 产品后生效 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
385 lines
14 KiB
Dart
385 lines
14 KiB
Dart
// screens/settings/purchase_dialog.dart — App 内在线购买/续费授权。
|
||
// 流程:套餐选择(标准/高级 × 月付/年付)→ POST /license/purchase 下单 →
|
||
// 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本弹窗轮询
|
||
// GET /license/purchase/:otn,webhook 续期到账后自动刷新授权并展示新到期日。
|
||
// 价格仅展示,实付以 pay 侧套餐表为准(下单响应回传 amount)。
|
||
import 'dart:async';
|
||
|
||
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/responsive/responsive.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 '../../core/utils/dialog_util.dart';
|
||
import '../../models/license.dart';
|
||
import '../../providers/license_provider.dart';
|
||
import '../../widgets/ds/ds_atoms.dart';
|
||
import '../../widgets/ds/ds_toast.dart';
|
||
|
||
Future<void> showPurchaseDialog(BuildContext context) {
|
||
return showAppDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => const _PurchaseDialog(),
|
||
);
|
||
}
|
||
|
||
enum _Phase { pick, waiting, success }
|
||
|
||
class _PurchaseDialog extends ConsumerStatefulWidget {
|
||
const _PurchaseDialog();
|
||
|
||
@override
|
||
ConsumerState<_PurchaseDialog> createState() => _PurchaseDialogState();
|
||
}
|
||
|
||
class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
|
||
_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 == 1;
|
||
LicensePlanGroup get _group =>
|
||
_tabIdx == 0 ? 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);
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_pollTimer?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
setState(() => _submitting = true);
|
||
try {
|
||
final order =
|
||
await ref.read(licenseRepositoryProvider).createPurchase(_plan.bizCode);
|
||
// 外部浏览器打开收银台:桌面端进网页支付,移动端由系统浏览器拉起支付宝
|
||
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;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return AlertDialog(
|
||
title: Text(switch (_phase) {
|
||
_Phase.pick => '在线购买 / 续费',
|
||
_Phase.waiting => '等待支付',
|
||
_Phase.success => '支付成功',
|
||
}),
|
||
content: SizedBox(
|
||
width: context.dialogWidth(460),
|
||
child: switch (_phase) {
|
||
_Phase.pick => _buildPick(t),
|
||
_Phase.waiting => _buildWaiting(t),
|
||
_Phase.success => _buildSuccess(t),
|
||
},
|
||
),
|
||
actions: switch (_phase) {
|
||
_Phase.pick => [
|
||
DsButton('取消',
|
||
onPressed:
|
||
_submitting ? null : () => Navigator.of(context).pop()),
|
||
DsButton(
|
||
_submitting
|
||
? '正在创建订单…'
|
||
: _isPromo && _promoUsed
|
||
? '本店已享受过'
|
||
: '提交订单 · ¥${_fmt(_plan.price)}',
|
||
variant: DsBtnVariant.primary,
|
||
onPressed:
|
||
_submitting || (_isPromo && _promoUsed) ? null : _submit),
|
||
],
|
||
_Phase.waiting => [
|
||
DsButton('稍后再说', onPressed: () => Navigator.of(context).pop()),
|
||
DsButton('我已完成支付',
|
||
variant: DsBtnVariant.primary,
|
||
onPressed: () => _poll(manual: true)),
|
||
],
|
||
_Phase.success => [
|
||
DsButton('完成',
|
||
variant: DsBtnVariant.primary,
|
||
onPressed: () => Navigator.of(context).pop()),
|
||
],
|
||
},
|
||
);
|
||
}
|
||
|
||
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 ['年付', '月付'],
|
||
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),
|
||
],
|
||
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)),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text('到期时间自动叠加,未到期续费不吃亏 · 支付宝支付',
|
||
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
|
||
],
|
||
);
|
||
}
|
||
|
||
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)),
|
||
],
|
||
);
|
||
}
|
||
|
||
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)),
|
||
],
|
||
);
|
||
}
|
||
|
||
static String _fmt(int n) =>
|
||
n.toString().replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},');
|
||
}
|