feat(client): 实时授权状态与会话失效处理
- 心跳读取 /auth/ping 回带的授权概况,直接刷新横幅/状态栏/只读门禁,省去单独轮询 /license/info - 账号被停用/删除(401 USER_DISABLED)即强制重新登录 - 令牌持久化经串行队列 + 会话代号守卫,杜绝续期写入与登出交叉把失效 token 写回 - 写操作门禁(write_guard)+ 授权文案(license_copy)按 grace/readonly/locked 分阶段降级 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,19 +12,21 @@ import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/app_info.dart';
|
||||
import '../../core/config/license_copy.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
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';
|
||||
import '../../models/shop.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
/// 初始选中的 Tab(0=酒行信息 … 4=授权 … 5=数据管理)。
|
||||
final int initialTab;
|
||||
const SettingsScreen({super.key, this.initialTab = 0});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
@@ -44,6 +46,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 6,
|
||||
initialIndex: widget.initialTab,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -276,17 +279,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
children: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_showEditUserDialog(context, u),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
_showEditUserDialog(context, u),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_showResetPasswordDialog(context, u),
|
||||
child: const Text('重置密码',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
_showResetPasswordDialog(context, u),
|
||||
child: const Text('重置密码',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
@@ -326,6 +333,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 过期降级说明
|
||||
_buildExpiryNotes(),
|
||||
const SizedBox(height: 16),
|
||||
// 激活码输入区
|
||||
_buildActivationCard(),
|
||||
],
|
||||
@@ -349,24 +359,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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} 天)';
|
||||
}
|
||||
final Color phaseColor = LicenseCopy.phaseColor(lic.phase);
|
||||
final String phaseText = LicenseCopy.statusText(lic);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -407,6 +401,53 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpiryNotes() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline,
|
||||
size: 16, color: AppTheme.textSecondary),
|
||||
SizedBox(width: 6),
|
||||
Text('过期说明',
|
||||
style:
|
||||
TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text('授权到期后会分阶段降级,请在到期前及时续费',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
for (final note in LicenseCopy.degradationNotes())
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, right: 8),
|
||||
child: Icon(Icons.circle,
|
||||
size: 5, color: AppTheme.textSecondary),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(note,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, height: 1.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivationCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -789,6 +830,19 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
style: TextStyle(color: AppTheme.textSecondary)),
|
||||
);
|
||||
}
|
||||
if (WriteGuard.licenseBlocked(ref)) {
|
||||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
lic == null ? '授权已过期,暂时无法导入' : LicenseCopy.writeBlockedToast(lic),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final currentUser = ref.watch(authStateProvider).user;
|
||||
final isSuperAdmin = currentUser?.role == 'superadmin';
|
||||
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
||||
|
||||
Reference in New Issue
Block a user