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:
wangjia
2026-06-10 07:08:35 +08:00
parent ebf79d0355
commit 97ebb18fd7
2 changed files with 267 additions and 83 deletions
+162 -83
View File
@@ -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<SettingsScreen> {
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<SettingsScreen> {
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<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() {
showAppDialog(
context: context,