Files
jiu/client/lib/screens/settings/purchase_dialog.dart
T
wangjia b3c9d98eae feat: ¥1 首月特惠套餐(新店专享,每店限购一次,官网+App 同步上线)
- 后端: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
2026-07-03 22:25:07 +08:00

385 lines
14 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_dialog.dart — App 内在线购买/续费授权。
// 流程:套餐选择(标准/高级 × 月付/年付)→ POST /license/purchase 下单 →
// 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本弹窗轮询
// GET /license/purchase/:otnwebhook 续期到账后自动刷新授权并展示新到期日。
// 价格仅展示,实付以 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]},');
}