feat(client): 在线购买改为设置页内联卡片,左侧菜单改「授权管理」

- purchase_dialog.dart → purchase_card.dart:弹窗改 PurchaseCard 内联卡片,
  三阶段(选套餐/待支付/成功)操作按钮改卡片内右对齐,新增「重新选择」回退
- 设置授权面板拆两张卡:授权管理(到期统计+兑换券+降级说明)+
  在线购买/续费(仅管理员可见,PurchaseCard 内联)
- 移除统计条与移动端的弹窗入口按钮

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 23:39:12 +08:00
parent 15ed05dc1e
commit 02db118f61
8 changed files with 186 additions and 189 deletions
@@ -0,0 +1,382 @@
// screens/settings/purchase_card.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/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<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 == 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;
}
}
/// 回到套餐选择(仅停止本地轮询,不影响已支付订单到账)。
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 ['年付', '月付'],
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)),
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]},');
}