Files
jiu/client/lib/screens/settings/purchase_card.dart
T

474 lines
18 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:qr_flutter/qr_flutter.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 '../../core/utils/money.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);
// 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;
_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;
// 购买完成后,选择页默认落到 高级版·年付(特惠只能买一次,
// 后续复购/升级的主推档位);本次若是特惠单,本店已享用置灰
if (_isPromo) _promoUsed = true;
_tabIdx = 2;
_cycleIdx = 0;
});
} else if (manual) {
showDsToast(context, '尚未确认到账,支付完成后通常数秒内到账');
}
} catch (e) {
if (mounted && manual) {
showDsToast(context, '查询失败:$e', bg: context.tokens.danger);
}
} finally {
_checking = false;
}
}
/// 回到套餐选择(仅停止本地轮询,不影响已支付订单到账)。
/// 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;
_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) {
final isQr = _order?.renderType == 'qr';
final amountMinor = _order?.amountMinor ?? 0;
final amountText = amountMinor > 0 ? '¥${yuanFromMinor(amountMinor)}' : '';
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(
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,
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('金额 $amountText',
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]},');
}