Files
jiu/client/lib/screens/settings/purchase_dialog.dart
T
wangjia 24123eb93c feat(client): App 内在线购买/续费授权(管理员支付宝下单+到账轮询)
- 新增套餐目录 license_plans.dart(标准/高级 × 月付/年付,与官网 checkout 一致)
- license 模型/仓库新增 PurchaseOrder/PurchaseStatusInfo 与下单、查单接口
- 系统设置授权面板:管理员显示「在线购买 / 续费」按钮,弹窗选套餐→
  外部浏览器打开支付宝收银台→3s 轮询到账→自动刷新授权并展示新到期日
- 用户手册(docs.md + user-manual.html)同步补充在线购买入口说明

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 21:57:03 +08:00

311 lines
11 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 _groupIdx = 0; // 0=标准版 1=高级版
int _cycleIdx = 0; // 0=年付 1=月付
bool _submitting = false;
bool _checking = false;
PurchaseOrder? _order;
DateTime? _newExpiresAt;
Timer? _pollTimer;
LicensePlanGroup get _group => LicensePlans.groups[_groupIdx];
LicensePlan get _plan => _cycleIdx == 0 ? _group.annual : _group.monthly;
String get _cycleLabel => _cycleIdx == 0 ? '年付' : '月付';
@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 ? '正在创建订单…' : '提交订单 · ¥${_fmt(_plan.price)}',
variant: DsBtnVariant.primary,
onPressed: _submitting ? 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) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
DsSeg(
items: [for (final g in LicensePlans.groups) g.name],
index: _groupIdx,
onChanged: (i) => setState(() => _groupIdx = i),
),
const SizedBox(width: 10),
DsSeg(
items: const ['年付', '月付'],
index: _cycleIdx,
onChanged: (i) => setState(() => _cycleIdx = i),
),
]),
const SizedBox(height: 14),
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 _group.feats)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: [
Icon(LucideIcons.check, size: 14, color: t.success),
const SizedBox(width: 8),
Expanded(
child: Text(f,
style: TextStyle(
fontSize: AppDims.fsSm, color: t.text))),
]),
),
],
),
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text('${_group.name} · $_cycleLabel · ${_plan.days} 天授权',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
Text('¥${_fmt(_plan.price)}',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: 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]},');
}