// screens/settings/purchase_card.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/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 createState() => _PurchaseCardState(); } class _PurchaseCardState extends ConsumerState { _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(); } Future _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 _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]},'); }