From 97ebb18fd7cf57a3f0b053400ec772ae8f54bae1 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 10 Jun 2026 07:08:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E6=8E=88=E6=9D=83=20Tab=20?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=20UI=20+=20=E5=88=B0=E6=9C=9F=E6=A8=AA?= =?UTF-8?q?=E5=B9=85/=E5=BC=B9=E7=AA=97=20(21F+21G)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21F — 激活与设备管理 UI: - settings_screen: 重构授权 Tab,按 phase 展示色阶状态 (normal=绿/grace=橙/readonly|locked=红) - 新增「激活授权码」卡片:文本框 + 激活按钮,调用 LicenseRepository.activate - 显示设备上限字段 (maxDevices) 21G — gating 横幅 + 到期弹窗: - app_shell: 引入 licenseProvider,needsAttention 时展示顶部横幅 (grace=深橙/readonly=深红/locked=红),横幅含「去激活」快捷入口 - 每 session 首次进入时弹出到期提醒 dialog(postFrameCallback 模式) 内容随 phase 调整文案,提供「稍后处理」/「立即前往设置」两个按钮 Co-Authored-By: Claude Sonnet 4.6 --- .../lib/screens/settings/settings_screen.dart | 245 ++++++++++++------ client/lib/screens/shell/app_shell.dart | 105 ++++++++ 2 files changed, 267 insertions(+), 83 deletions(-) diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index be98093..6a439f9 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -16,6 +16,7 @@ import '../../core/theme/app_theme.dart'; import '../../models/number_rule.dart'; import '../../models/user.dart'; import '../../providers/license_provider.dart'; +import '../../repositories/license_repository.dart'; import '../../providers/number_rule_provider.dart'; import '../../providers/user_provider.dart'; import '../../providers/shop_provider.dart'; @@ -301,7 +302,7 @@ class _SettingsScreenState extends ConsumerState { const Text('授权信息', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(height: 4), - const Text('当前门店的授权状态与到期信息', + const Text('当前门店的授权状态与设备绑定', style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)), const SizedBox(height: 16), Card( @@ -309,96 +310,174 @@ class _SettingsScreenState extends ConsumerState { padding: const EdgeInsets.all(20), child: licenseAsync.when( loading: () => const _ParamRow(label: '授权状态', value: '加载中…'), - error: (_, __) => const _ParamRow(label: '授权状态', value: '暂无授权信息'), - data: (lic) { - if (lic == null) { - return Column( - children: [ - const _ParamRow(label: '授权状态', value: '未激活'), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _showRenewLicenseDialog, - icon: const Icon(Icons.card_membership, size: 16), - label: const Text('续费 / 升级授权'), - ), - ], - ); - } - final statusColor = lic.isExpired - ? AppTheme.danger - : lic.isActive - ? AppTheme.success - : AppTheme.textSecondary; - final statusText = lic.isExpired - ? '已过期' - : lic.isActive - ? '正常' - : '已停用'; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ParamRow(label: '授权类型', value: lic.typeLabel), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - const SizedBox( - width: 180, - child: Text('授权状态', - style: TextStyle( - fontSize: 14, - color: AppTheme.textSecondary)), - ), - Text(statusText, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: statusColor)), - if (lic.daysRemaining != null && - lic.daysRemaining! <= 30) ...[ - const SizedBox(width: 8), - Chip( - label: Text( - lic.isExpired - ? '已过期' - : '剩余 ${lic.daysRemaining} 天', - style: const TextStyle( - fontSize: 11, color: Colors.white), - ), - backgroundColor: - lic.isExpired ? AppTheme.danger : Colors.orange, - padding: EdgeInsets.zero, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - ], - ], - ), - ), - if (lic.expiresAt != null) - _ParamRow( - label: '到期时间', - value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!)) - else - const _ParamRow(label: '到期时间', value: '永久有效'), - _ParamRow(label: '设备上限', value: '${lic.maxDevices} 台'), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _showRenewLicenseDialog, - icon: const Icon(Icons.card_membership, size: 16), - label: const Text('续费 / 升级授权'), - ), - ], - ); - }, + error: (_, __) => + const _ParamRow(label: '授权状态', value: '暂无授权信息'), + data: (lic) => _buildLicenseContent(lic), ), ), ), + const SizedBox(height: 16), + // 激活码输入区 + _buildActivationCard(), ], ), ); } + Widget _buildLicenseContent(LicenseInfo? lic) { + if (lic == null) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _ParamRow(label: '授权状态', value: '未激活'), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _showRenewLicenseDialog, + icon: const Icon(Icons.card_membership, size: 16), + label: const Text('续费 / 获取授权码'), + ), + ], + ); + } + + Color phaseColor; + String phaseText; + switch (lic.phase) { + case 'grace': + phaseColor = Colors.orange; + phaseText = '宽限期(剩余 ${lic.daysRemaining ?? 0} 天到期)'; + case 'readonly': + phaseColor = AppTheme.danger; + phaseText = '已过期 · 只读模式'; + case 'locked': + phaseColor = AppTheme.danger; + phaseText = '已锁定 · 请立即续费'; + default: + phaseColor = AppTheme.success; + phaseText = lic.expiresAt == null + ? '正常(永久授权)' + : '正常(剩余 ${lic.daysRemaining ?? 0} 天)'; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ParamRow(label: '授权类型', value: lic.typeLabel), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const SizedBox( + width: 180, + child: Text('授权状态', + style: TextStyle( + fontSize: 14, color: AppTheme.textSecondary)), + ), + Text(phaseText, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: phaseColor)), + ], + ), + ), + if (lic.expiresAt != null) + _ParamRow( + label: '到期时间', + value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!)) + else + const _ParamRow(label: '到期时间', value: '永久有效'), + _ParamRow(label: '设备上限', value: '${lic.maxDevices} 台'), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _showRenewLicenseDialog, + icon: const Icon(Icons.card_membership, size: 16), + label: const Text('续费 / 升级授权'), + ), + ], + ); + } + + Widget _buildActivationCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('激活授权码', + style: + TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + const Text('输入从官方渠道获得的授权码以激活或续期', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _licenseKeyCtrl, + decoration: const InputDecoration( + hintText: 'ey... 授权码', + isDense: true, + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + style: const TextStyle(fontSize: 13), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _isActivating ? null : _activateLicense, + child: _isActivating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('激活'), + ), + ], + ), + ], + ), + ), + ); + } + + late final TextEditingController _licenseKeyCtrl = + TextEditingController(); + bool _isActivating = false; + + Future _activateLicense() async { + final key = _licenseKeyCtrl.text.trim(); + if (key.isEmpty) return; + setState(() => _isActivating = true); + try { + final repo = ref.read(licenseRepositoryProvider); + await repo.activate(key); + _licenseKeyCtrl.clear(); + await ref.read(licenseProvider.notifier).reload(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('授权激活成功'), backgroundColor: AppTheme.success), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _isActivating = false); + } + } + void _showRenewLicenseDialog() { showAppDialog( context: context, diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 5b059c3..bb6e356 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -13,6 +13,7 @@ import '../../providers/connectivity_provider.dart'; import '../../providers/shop_provider.dart'; import '../../providers/update_provider.dart'; import '../../core/update/app_updater.dart'; +import '../../providers/license_provider.dart'; class AppShell extends ConsumerStatefulWidget { final Widget child; @@ -26,6 +27,7 @@ class _AppShellState extends ConsumerState { bool _sidebarExpanded = true; final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now()); bool _forceDialogShown = false; + bool _licenseDialogShown = false; final GlobalKey _scaffoldKey = GlobalKey(); void _showForceUpdateDialog( @@ -157,6 +159,7 @@ class _AppShellState extends ConsumerState { final updateInfo = ref.watch(updateProvider).valueOrNull; final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; + final licenseInfo = ref.watch(licenseProvider).valueOrNull; return SelectionArea( child: Scaffold( @@ -350,6 +353,23 @@ class _AppShellState extends ConsumerState { }); return const SizedBox.shrink(); }), + // License expiry banner + if (licenseInfo != null && + licenseInfo.needsAttention) + _buildLicenseBanner(licenseInfo), + // License expiry dialog (once per session) + if (licenseInfo != null && + licenseInfo.needsAttention && + !_licenseDialogShown) + Builder(builder: (ctx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_licenseDialogShown && mounted) { + _licenseDialogShown = true; + _showLicenseExpiryDialog(ctx, licenseInfo); + } + }); + return const SizedBox.shrink(); + }), // Offline banner if (!isOnline) Container( @@ -559,6 +579,91 @@ class _StatusItem extends StatelessWidget { ], ); } + + Widget _buildLicenseBanner(LicenseInfo lic) { + final Color bg; + final String msg; + switch (lic.phase) { + case 'locked': + bg = AppTheme.danger; + msg = '授权已锁定,所有写操作已停用 — 请立即续费或激活新授权码'; + case 'readonly': + bg = const Color(0xFFB71C1C); + msg = '授权已过期,当前为只读模式(剩余宽限期 ${lic.daysRemaining ?? 0} 天后彻底锁定)'; + default: // grace + bg = const Color(0xFFE65100); + msg = '授权将于 ${lic.daysRemaining ?? 0} 天后到期,请及时续费'; + } + return Container( + width: double.infinity, + color: bg, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, + size: 16, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: Text(msg, + style: const TextStyle(color: Colors.white, fontSize: 13), + overflow: TextOverflow.ellipsis), + ), + TextButton( + onPressed: () => context.go('/settings'), + style: + TextButton.styleFrom(foregroundColor: Colors.white70), + child: const Text('去激活'), + ), + ], + ), + ); + } + + void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) { + final String title; + final String body; + final Color titleColor; + switch (lic.phase) { + case 'locked': + title = '授权已锁定'; + body = '您的授权已到期超过 15 天,所有写操作已停用。\n请前往「设置 → 授权」激活新的授权码,或联系客服续费。'; + titleColor = AppTheme.danger; + case 'readonly': + title = '授权已过期 · 只读模式'; + body = '您的授权已过期,系统进入只读模式,无法执行任何写操作。\n到期 15 天后将彻底锁定登录,请尽快续费。'; + titleColor = AppTheme.danger; + default: // grace + title = '授权即将到期'; + body = '您的授权将在 ${lic.daysRemaining ?? 0} 天后到期,到期后系统进入只读模式。\n请提前联系客服续费,避免影响正常使用。'; + titleColor = Colors.orange[800]!; + } + showDialog( + context: ctx, + barrierDismissible: true, + builder: (_) => AlertDialog( + title: Row(children: [ + Icon(Icons.warning_amber_rounded, color: titleColor, size: 20), + const SizedBox(width: 8), + Text(title, style: TextStyle(color: titleColor, fontSize: 16)), + ]), + content: Text(body, style: const TextStyle(fontSize: 14)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(_), + child: const Text('稍后处理'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: titleColor), + onPressed: () { + Navigator.pop(_); + ctx.go('/settings'); + }, + child: const Text('立即前往', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } } class _StatusDivider extends StatelessWidget {