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:
@@ -16,6 +16,7 @@ import '../../core/theme/app_theme.dart';
|
|||||||
import '../../models/number_rule.dart';
|
import '../../models/number_rule.dart';
|
||||||
import '../../models/user.dart';
|
import '../../models/user.dart';
|
||||||
import '../../providers/license_provider.dart';
|
import '../../providers/license_provider.dart';
|
||||||
|
import '../../repositories/license_repository.dart';
|
||||||
import '../../providers/number_rule_provider.dart';
|
import '../../providers/number_rule_provider.dart';
|
||||||
import '../../providers/user_provider.dart';
|
import '../../providers/user_provider.dart';
|
||||||
import '../../providers/shop_provider.dart';
|
import '../../providers/shop_provider.dart';
|
||||||
@@ -301,7 +302,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
const Text('授权信息',
|
const Text('授权信息',
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text('当前门店的授权状态与到期信息',
|
const Text('当前门店的授权状态与设备绑定',
|
||||||
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Card(
|
Card(
|
||||||
@@ -309,96 +310,174 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: licenseAsync.when(
|
child: licenseAsync.when(
|
||||||
loading: () => const _ParamRow(label: '授权状态', value: '加载中…'),
|
loading: () => const _ParamRow(label: '授权状态', value: '加载中…'),
|
||||||
error: (_, __) => const _ParamRow(label: '授权状态', value: '暂无授权信息'),
|
error: (_, __) =>
|
||||||
data: (lic) {
|
const _ParamRow(label: '授权状态', value: '暂无授权信息'),
|
||||||
if (lic == null) {
|
data: (lic) => _buildLicenseContent(lic),
|
||||||
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('续费 / 升级授权'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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<void> _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() {
|
void _showRenewLicenseDialog() {
|
||||||
showAppDialog(
|
showAppDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../providers/connectivity_provider.dart';
|
|||||||
import '../../providers/shop_provider.dart';
|
import '../../providers/shop_provider.dart';
|
||||||
import '../../providers/update_provider.dart';
|
import '../../providers/update_provider.dart';
|
||||||
import '../../core/update/app_updater.dart';
|
import '../../core/update/app_updater.dart';
|
||||||
|
import '../../providers/license_provider.dart';
|
||||||
|
|
||||||
class AppShell extends ConsumerStatefulWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -26,6 +27,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
bool _sidebarExpanded = true;
|
bool _sidebarExpanded = true;
|
||||||
final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now());
|
final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now());
|
||||||
bool _forceDialogShown = false;
|
bool _forceDialogShown = false;
|
||||||
|
bool _licenseDialogShown = false;
|
||||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
|
|
||||||
void _showForceUpdateDialog(
|
void _showForceUpdateDialog(
|
||||||
@@ -157,6 +159,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||||||
final appVersion =
|
final appVersion =
|
||||||
ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||||||
|
final licenseInfo = ref.watch(licenseProvider).valueOrNull;
|
||||||
|
|
||||||
return SelectionArea(
|
return SelectionArea(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
@@ -350,6 +353,23 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
});
|
});
|
||||||
return const SizedBox.shrink();
|
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
|
// Offline banner
|
||||||
if (!isOnline)
|
if (!isOnline)
|
||||||
Container(
|
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 {
|
class _StatusDivider extends StatelessWidget {
|
||||||
|
|||||||
Reference in New Issue
Block a user