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:
wangjia
2026-06-19 07:34:07 +08:00
parent e41085a878
commit 32bd64d676
23 changed files with 1319 additions and 408 deletions
@@ -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);