feat(client): 授权 Tab 激活 UI + 到期横幅/弹窗 (21F+21G)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<AppShell> {
|
||||
bool _sidebarExpanded = true;
|
||||
final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now());
|
||||
bool _forceDialogShown = false;
|
||||
bool _licenseDialogShown = false;
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
|
||||
void _showForceUpdateDialog(
|
||||
@@ -157,6 +159,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
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<AppShell> {
|
||||
});
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user