diff --git a/client/lib/screens/settings/purchase_dialog.dart b/client/lib/screens/settings/purchase_card.dart similarity index 78% rename from client/lib/screens/settings/purchase_dialog.dart rename to client/lib/screens/settings/purchase_card.dart index 48fe8f6..00fc357 100644 --- a/client/lib/screens/settings/purchase_dialog.dart +++ b/client/lib/screens/settings/purchase_card.dart @@ -1,6 +1,6 @@ -// screens/settings/purchase_dialog.dart — App 内在线购买/续费授权。 -// 流程:套餐选择(标准/高级 × 月付/年付)→ POST /license/purchase 下单 → -// 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本弹窗轮询 +// screens/settings/purchase_card.dart — App 内在线购买/续费授权(内联卡片)。 +// 流程:套餐选择(标准/首月特惠/高级,非特惠可切月付/年付)→ POST /license/purchase +// 下单 → 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本卡片轮询 // GET /license/purchase/:otn,webhook 续期到账后自动刷新授权并展示新到期日。 // 价格仅展示,实付以 pay 侧套餐表为准(下单响应回传 amount)。 import 'dart:async'; @@ -12,35 +12,25 @@ 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 showPurchaseDialog(BuildContext context) { - return showAppDialog( - context: context, - barrierDismissible: false, - builder: (_) => const _PurchaseDialog(), - ); -} - enum _Phase { pick, waiting, success } -class _PurchaseDialog extends ConsumerStatefulWidget { - const _PurchaseDialog(); +/// 在线购买/续费卡片内容(由设置页包进 _SettingsCard,仅管理员可见)。 +class PurchaseCard extends ConsumerStatefulWidget { + const PurchaseCard({super.key}); @override - ConsumerState<_PurchaseDialog> createState() => _PurchaseDialogState(); + ConsumerState createState() => _PurchaseCardState(); } -class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { +class _PurchaseCardState extends ConsumerState { _Phase _phase = _Phase.pick; int _tabIdx = 0; // 0=标准版 1=首月特惠 2=高级版 int _cycleIdx = 0; // 0=年付 1=月付 @@ -77,8 +67,9 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { Future _submit() async { setState(() => _submitting = true); try { - final order = - await ref.read(licenseRepositoryProvider).createPurchase(_plan.bizCode); + final order = await ref + .read(licenseRepositoryProvider) + .createPurchase(_plan.bizCode); // 外部浏览器打开收银台:桌面端进网页支付,移动端由系统浏览器拉起支付宝 await launchUrl(Uri.parse(order.payUrl), mode: LaunchMode.externalApplication); @@ -88,8 +79,7 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { _phase = _Phase.waiting; _submitting = false; }); - _pollTimer = - Timer.periodic(const Duration(seconds: 3), (_) => _poll()); + _pollTimer = Timer.periodic(const Duration(seconds: 3), (_) => _poll()); } catch (e) { if (!mounted) return; setState(() => _submitting = false); @@ -102,8 +92,9 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { if (order == null || _checking) return; _checking = true; try { - final st = - await ref.read(licenseRepositoryProvider).purchaseStatus(order.outTradeNo); + final st = await ref + .read(licenseRepositoryProvider) + .purchaseStatus(order.outTradeNo); if (!mounted) return; if (st.isPaid) { _pollTimer?.cancel(); @@ -124,49 +115,26 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { } } + /// 回到套餐选择(仅停止本地轮询,不影响已支付订单到账)。 + void _backToPick() { + _pollTimer?.cancel(); + setState(() { + _order = null; + _newExpiresAt = null; + _phase = _Phase.pick; + }); + } + @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()), - ], + // 与授权卡兑换区一致收窄内容宽度,避免宽屏卡片内元素拉伸过长 + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: switch (_phase) { + _Phase.pick => _buildPick(t), + _Phase.waiting => _buildWaiting(t), + _Phase.success => _buildSuccess(t), }, ); } @@ -291,6 +259,19 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { 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), + ), ], ); } @@ -308,7 +289,7 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { ), const SizedBox(width: 12), Expanded( - child: Text('已在浏览器打开支付宝收银台,完成支付后本窗口会自动确认到账。', + child: Text('已在浏览器打开支付宝收银台,完成支付后本卡片会自动确认到账。', style: TextStyle(fontSize: AppDims.fsSm, color: t.text)), ), ]), @@ -324,7 +305,8 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('订单号', style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + Text('订单号', + style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), const SizedBox(height: 4), Text(_order?.outTradeNo ?? '—', style: const TextStyle( @@ -338,8 +320,19 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { ), ), const SizedBox(height: 10), - Text('关闭本窗口不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。', + 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)), + ], + ), ], ); } @@ -364,8 +357,7 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { ), const SizedBox(width: 12), Expanded( - child: Text( - expires != null ? '门店授权已续期至 $expires。' : '门店授权已续期。', + child: Text(expires != null ? '门店授权已续期至 $expires。' : '门店授权已续期。', style: TextStyle( fontSize: AppDims.fsTitle, fontWeight: FontWeight.w600, @@ -375,10 +367,16 @@ class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> { 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]},'); + static String _fmt(int n) => n + .toString() + .replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},'); } diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 2098385..c863beb 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,6 +1,6 @@ // screens/settings/settings_screen.dart — 系统设置(照原型 settings.html 重建)。 // 布局:.head + .settings grid(左 .subnav 200px + 右面板卡)。子导航 5 项 = -// 原型 4(门店信息/用户管理/授权兑换券/偏好设置)+ 保留真实功能「编号规则」。 +// 原型 4(门店信息/用户管理/授权管理/偏好设置)+ 保留真实功能「编号规则」。 // 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。 // 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。 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_table.dart'; import '../../widgets/write_guard.dart'; -import 'purchase_dialog.dart'; +import 'purchase_card.dart'; import 'users_screen.dart' show userRoleBadge, userStatusBadge; import '../../core/theme/app_fonts.dart'; import '../../widgets/ds/ds_toast.dart'; @@ -53,7 +53,7 @@ class _SettingsScreenState extends ConsumerState { (LucideIcons.house, '门店信息'), (LucideIcons.userPlus, '用户管理'), (LucideIcons.hash, '编号规则'), - (LucideIcons.trendingUp, '授权兑换券'), + (LucideIcons.trendingUp, '授权管理'), (LucideIcons.contrast, '偏好设置'), ]; 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 { const _LicensePanel(); @@ -696,132 +696,131 @@ class _LicensePanelState extends ConsumerState<_LicensePanel> { ? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999) : null; - return _SettingsCard( - title: '授权管理', - desc: '在线购买或使用兑换券续期,时长在现有到期时间上叠加', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 原型 .lic 统计条:bg 底 / border / r-md / pad 18 20 - // 主 CTA「在线购买 / 续费」宽屏并排在统计条内,窄屏换行到统计条下方通栏 - Container( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), - 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), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SettingsCard( + title: '授权管理', + desc: '使用兑换券续期,时长在现有到期时间上叠加', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 原型 .lic 统计条:bg 底 / border / r-md / pad 18 20 Container( - width: 46, - height: 46, + padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), decoration: BoxDecoration( - color: t.okSoft, + color: t.bg, + border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), ), - child: - Icon(LucideIcons.shieldCheck, size: 22, color: t.success), + child: Row(children: [ + _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), + ), + ]), ), - ]), - ), - if (isAdmin && context.isMobile) ...[ - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: DsButton('在线购买 / 续费', - variant: DsBtnVariant.primary, - onPressed: () => showPurchaseDialog(context)), - ), - ], - const SizedBox(height: 16), - // 兑换(原型 .redeem:input + 按钮,max 520) - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - 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(height: 16), + // 兑换(原型 .redeem:input + 按钮,max 520) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + 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)), ), - ]), - ], - 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,主 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)), ), + ]), ], - ), + 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(), ), ], - ), + ], ); } diff --git a/client/test/golden/goldens/settings_a.png b/client/test/golden/goldens/settings_a.png index 7f793ac..553a4c6 100644 Binary files a/client/test/golden/goldens/settings_a.png and b/client/test/golden/goldens/settings_a.png differ diff --git a/client/test/golden/goldens/settings_b.png b/client/test/golden/goldens/settings_b.png index 8c8095d..6a0a51f 100644 Binary files a/client/test/golden/goldens/settings_b.png and b/client/test/golden/goldens/settings_b.png differ diff --git a/client/test/golden/goldens/settings_c.png b/client/test/golden/goldens/settings_c.png index 4cce889..35c216d 100644 Binary files a/client/test/golden/goldens/settings_c.png and b/client/test/golden/goldens/settings_c.png differ diff --git a/client/test/golden/goldens/settings_mobile_a.png b/client/test/golden/goldens/settings_mobile_a.png index f860706..3610a36 100644 Binary files a/client/test/golden/goldens/settings_mobile_a.png and b/client/test/golden/goldens/settings_mobile_a.png differ diff --git a/client/test/golden/goldens/settings_mobile_b.png b/client/test/golden/goldens/settings_mobile_b.png index 9bb0b16..4c903b7 100644 Binary files a/client/test/golden/goldens/settings_mobile_b.png and b/client/test/golden/goldens/settings_mobile_b.png differ diff --git a/client/test/golden/goldens/settings_mobile_c.png b/client/test/golden/goldens/settings_mobile_c.png index 60a665d..fccb49b 100644 Binary files a/client/test/golden/goldens/settings_mobile_c.png and b/client/test/golden/goldens/settings_mobile_c.png differ