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
@@ -1,6 +1,6 @@
// screens/settings/purchase_dialog.dart App 线/ // screens/settings/purchase_card.dart App 线/
// / × / POST /license/purchase // /// POST /license/purchase
// pay App // pay App
// GET /license/purchase/:otnwebhook // GET /license/purchase/:otnwebhook
// pay amount // pay amount
import 'dart:async'; import 'dart:async';
@@ -12,35 +12,25 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../core/config/license_plans.dart'; import '../../core/config/license_plans.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart'; import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_fonts.dart'; import '../../core/theme/app_fonts.dart';
import '../../core/theme/app_tokens.dart'; import '../../core/theme/app_tokens.dart';
import '../../core/theme/context_tokens.dart'; import '../../core/theme/context_tokens.dart';
import '../../core/utils/dialog_util.dart';
import '../../models/license.dart';
import '../../providers/license_provider.dart'; import '../../providers/license_provider.dart';
import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.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 } enum _Phase { pick, waiting, success }
class _PurchaseDialog extends ConsumerStatefulWidget { /// 线/ _SettingsCard
const _PurchaseDialog(); class PurchaseCard extends ConsumerStatefulWidget {
const PurchaseCard({super.key});
@override @override
ConsumerState<_PurchaseDialog> createState() => _PurchaseDialogState(); ConsumerState<PurchaseCard> createState() => _PurchaseCardState();
} }
class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { class _PurchaseCardState extends ConsumerState<PurchaseCard> {
_Phase _phase = _Phase.pick; _Phase _phase = _Phase.pick;
int _tabIdx = 0; // 0= 1= 2= int _tabIdx = 0; // 0= 1= 2=
int _cycleIdx = 0; // 0= 1= int _cycleIdx = 0; // 0= 1=
@@ -77,8 +67,9 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
Future<void> _submit() async { Future<void> _submit() async {
setState(() => _submitting = true); setState(() => _submitting = true);
try { try {
final order = final order = await ref
await ref.read(licenseRepositoryProvider).createPurchase(_plan.bizCode); .read(licenseRepositoryProvider)
.createPurchase(_plan.bizCode);
// //
await launchUrl(Uri.parse(order.payUrl), await launchUrl(Uri.parse(order.payUrl),
mode: LaunchMode.externalApplication); mode: LaunchMode.externalApplication);
@@ -88,8 +79,7 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
_phase = _Phase.waiting; _phase = _Phase.waiting;
_submitting = false; _submitting = false;
}); });
_pollTimer = _pollTimer = Timer.periodic(const Duration(seconds: 3), (_) => _poll());
Timer.periodic(const Duration(seconds: 3), (_) => _poll());
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _submitting = false); setState(() => _submitting = false);
@@ -102,8 +92,9 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
if (order == null || _checking) return; if (order == null || _checking) return;
_checking = true; _checking = true;
try { try {
final st = final st = await ref
await ref.read(licenseRepositoryProvider).purchaseStatus(order.outTradeNo); .read(licenseRepositoryProvider)
.purchaseStatus(order.outTradeNo);
if (!mounted) return; if (!mounted) return;
if (st.isPaid) { if (st.isPaid) {
_pollTimer?.cancel(); _pollTimer?.cancel();
@@ -124,49 +115,26 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
} }
} }
///
void _backToPick() {
_pollTimer?.cancel();
setState(() {
_order = null;
_newExpiresAt = null;
_phase = _Phase.pick;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = context.tokens; final t = context.tokens;
return AlertDialog( //
title: Text(switch (_phase) { return ConstrainedBox(
_Phase.pick => '在线购买 / 续费', constraints: const BoxConstraints(maxWidth: 560),
_Phase.waiting => '等待支付', child: switch (_phase) {
_Phase.success => '支付成功', _Phase.pick => _buildPick(t),
}), _Phase.waiting => _buildWaiting(t),
content: SizedBox( _Phase.success => _buildSuccess(t),
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()),
],
}, },
); );
} }
@@ -291,6 +259,19 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
const SizedBox(height: 6), const SizedBox(height: 6),
Text('到期时间自动叠加,未到期续费不吃亏 · 支付宝支付', Text('到期时间自动叠加,未到期续费不吃亏 · 支付宝支付',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), 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),
),
], ],
); );
} }
@@ -308,7 +289,7 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text('已在浏览器打开支付宝收银台,完成支付后本窗口会自动确认到账。', child: Text('已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。',
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)), style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
), ),
]), ]),
@@ -324,7 +305,8 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('订单号', style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), Text('订单号',
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(_order?.outTradeNo ?? '', Text(_order?.outTradeNo ?? '',
style: const TextStyle( style: const TextStyle(
@@ -338,8 +320,19 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text('关闭本窗口不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。', Text('离开本页不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), 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)),
],
),
], ],
); );
} }
@@ -364,8 +357,7 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(expires != null ? '门店授权已续期至 $expires' : '门店授权已续期。',
expires != null ? '门店授权已续期至 $expires' : '门店授权已续期。',
style: TextStyle( style: TextStyle(
fontSize: AppDims.fsTitle, fontSize: AppDims.fsTitle,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -375,10 +367,16 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
const SizedBox(height: 10), const SizedBox(height: 10),
Text('订单号 ${_order?.outTradeNo ?? ''}', Text('订单号 ${_order?.outTradeNo ?? ''}',
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), 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) => static String _fmt(int n) => n
n.toString().replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},'); .toString()
.replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},');
} }
+117 -118
View File
@@ -1,6 +1,6 @@
// screens/settings/settings_screen.dart — 系统设置(照原型 settings.html 重建)。 // screens/settings/settings_screen.dart — 系统设置(照原型 settings.html 重建)。
// 布局:.head + .settings grid(左 .subnav 200px + 右面板卡)。子导航 5 项 = // 布局:.head + .settings grid(左 .subnav 200px + 右面板卡)。子导航 5 项 =
// 原型 4(门店信息/用户管理/授权兑换券/偏好设置)+ 保留真实功能「编号规则」。 // 原型 4(门店信息/用户管理/授权管理/偏好设置)+ 保留真实功能「编号规则」。
// 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。 // 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。
// 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。 // 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@@ -32,7 +32,7 @@ import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_menu.dart'; import '../../widgets/ds/ds_menu.dart';
import '../../widgets/ds/ds_table.dart'; import '../../widgets/ds/ds_table.dart';
import '../../widgets/write_guard.dart'; import '../../widgets/write_guard.dart';
import 'purchase_dialog.dart'; import 'purchase_card.dart';
import 'users_screen.dart' show userRoleBadge, userStatusBadge; import 'users_screen.dart' show userRoleBadge, userStatusBadge;
import '../../core/theme/app_fonts.dart'; import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart'; import '../../widgets/ds/ds_toast.dart';
@@ -53,7 +53,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
(LucideIcons.house, '门店信息'), (LucideIcons.house, '门店信息'),
(LucideIcons.userPlus, '用户管理'), (LucideIcons.userPlus, '用户管理'),
(LucideIcons.hash, '编号规则'), (LucideIcons.hash, '编号规则'),
(LucideIcons.trendingUp, '授权兑换券'), (LucideIcons.trendingUp, '授权管理'),
(LucideIcons.contrast, '偏好设置'), (LucideIcons.contrast, '偏好设置'),
]; ];
late int _panel = widget.initialTab.clamp(0, _navs.length - 1); late int _panel = widget.initialTab.clamp(0, _navs.length - 1);
@@ -641,7 +641,7 @@ class _StoreInfoPanelState extends ConsumerState<_StoreInfoPanel> {
} }
} }
// ── 面板 4:授权兑换券 ─────────────────────────────────────── // ── 面板 4:授权管理 ───────────────────────────────────────
class _LicensePanel extends ConsumerStatefulWidget { class _LicensePanel extends ConsumerStatefulWidget {
const _LicensePanel(); const _LicensePanel();
@@ -696,132 +696,131 @@ class _LicensePanelState extends ConsumerState<_LicensePanel> {
? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999) ? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999)
: null; : null;
return _SettingsCard( return Column(
title: '授权管理', crossAxisAlignment: CrossAxisAlignment.stretch,
desc: '在线购买或使用兑换券续期,时长在现有到期时间上叠加', children: [
child: Column( _SettingsCard(
crossAxisAlignment: CrossAxisAlignment.start, title: '授权管理',
children: [ desc: '使用兑换券续期,时长在现有到期时间上叠加',
// 原型 .lic 统计条:bg 底 / border / r-md / pad 18 20 child: Column(
// 主 CTA「在线购买 / 续费」宽屏并排在统计条内,窄屏换行到统计条下方通栏 crossAxisAlignment: CrossAxisAlignment.start,
Container( children: [
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), // 原型 .lic 统计条:bg 底 / border / r-md / pad 18 20
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
_stat(t, '授权到期日', expires),
const SizedBox(width: 24),
_stat(t, '剩余天数', days != null ? '剩余 $days' : '', warn: true),
const Spacer(),
if (isAdmin && !context.isMobile) ...[
DsButton('在线购买 / 续费',
variant: DsBtnVariant.primary,
onPressed: () => showPurchaseDialog(context)),
const SizedBox(width: 14),
],
Container( Container(
width: 46, padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
height: 46,
decoration: BoxDecoration( decoration: BoxDecoration(
color: t.okSoft, color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd), borderRadius: BorderRadius.circular(AppDims.rMd),
), ),
child: child: Row(children: [
Icon(LucideIcons.shieldCheck, size: 22, color: t.success), _stat(t, '授权到期日', expires),
const SizedBox(width: 24),
_stat(t, '剩余天数', days != null ? '剩余 $days' : '',
warn: true),
const Spacer(),
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: t.okSoft,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Icon(LucideIcons.shieldCheck,
size: 22, color: t.success),
),
]),
), ),
]), const SizedBox(height: 16),
), // 兑换(原型 .redeeminput + 按钮,max 520
if (isAdmin && context.isMobile) ...[ ConstrainedBox(
const SizedBox(height: 12), constraints: const BoxConstraints(maxWidth: 520),
SizedBox( child: Row(
width: double.infinity, crossAxisAlignment: CrossAxisAlignment.end,
child: DsButton('在线购买 / 续费', children: [
variant: DsBtnVariant.primary, Expanded(
onPressed: () => showPurchaseDialog(context)), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
], children: [
const SizedBox(height: 16), Text('兑换券短码',
// 兑换(原型 .redeeminput + 按钮,max 520 style: TextStyle(
ConstrainedBox( fontSize: AppDims.fsSm, color: t.muted)),
constraints: const BoxConstraints(maxWidth: 520), const SizedBox(height: 6),
child: Row( DsInput(
crossAxisAlignment: CrossAxisAlignment.end, controller: _voucherCtrl,
children: [ hintText: '输入兑换券短码',
Expanded( ),
child: Column( ],
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('兑换券短码',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
DsInput(
controller: _voucherCtrl,
hintText: '输入兑换券短码',
), ),
], ),
), const SizedBox(width: 10),
WriteGuard(
child: DsButton(_redeeming ? '兑换中…' : '兑换续期',
variant: DsBtnVariant.primary,
onPressed: _redeeming ? null : _redeem),
),
],
), ),
const SizedBox(width: 10),
WriteGuard(
child: DsButton(_redeeming ? '兑换中…' : '兑换续期',
variant: DsBtnVariant.primary,
onPressed: _redeeming ? null : _redeem),
),
],
),
),
// 在线购买仅管理员(后端 purchase 接口 admin only,主 CTA 已上移到统计条);
// 其他角色引导看官网价格页
if (!isAdmin) ...[
const SizedBox(height: 14),
Row(children: [
Text('在线购买请联系门店管理员 · ',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
onTap: () =>
launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
child: Text('查看套餐价格 →',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.primary)),
), ),
]), // 在线购买仅管理员(后端 purchase 接口 admin only,主 CTA 已上移到统计条);
], // 其他角色引导看官网价格页
const SizedBox(height: 16), if (!isAdmin) ...[
// 过期降级说明(现有能力保留) const SizedBox(height: 14),
Container( Row(children: [
padding: const EdgeInsets.all(14), Text('在线购买请联系门店管理员 · ',
decoration: BoxDecoration( style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
color: t.bg, InkWell(
border: Border.all(color: t.borderSubtle), onTap: () =>
borderRadius: BorderRadius.circular(AppDims.rMd), launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
), child: Text('查看套餐价格 →',
child: Column( style: TextStyle(
crossAxisAlignment: CrossAxisAlignment.start, fontSize: AppDims.fsSm,
children: [ fontWeight: FontWeight.w600,
Text('到期后的降级规则', color: t.primary)),
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.heading)),
const SizedBox(height: 6),
for (final note in LicenseCopy.degradationNotes())
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text('· $note',
style:
TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
), ),
]),
], ],
), const SizedBox(height: 16),
// 过期降级说明(现有能力保留)
Container(
padding: const EdgeInsets.all(14),
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.fsSm,
fontWeight: FontWeight.w600,
color: t.heading)),
const SizedBox(height: 6),
for (final note in LicenseCopy.degradationNotes())
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text('· $note',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
),
],
),
),
],
),
),
// 在线购买仅管理员(后端 purchase 接口 admin only),单独一张卡内联展示
if (isAdmin) ...[
const SizedBox(height: 16),
const _SettingsCard(
title: '在线购买 / 续费',
desc: '选择套餐支付宝支付,到账后授权自动续期',
child: PurchaseCard(),
), ),
], ],
), ],
); );
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB