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
+64 -9
View File
@@ -1,26 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth/auth_state.dart';
import '../core/config/license_copy.dart';
import '../providers/license_provider.dart';
/// 写操作守卫:当前登录用户为只读角色(role == 'readonly')时,
/// 隐藏被包裹的写操作控件(新增/编辑/删除/审核/提交/结清/导入…)
/// 写操作守卫:按「角色只读」和「授权过期」两种受限场景**区分处理**被包裹的写控件
/// (新增/编辑/删除/审核/提交/结清/导入…)
///
/// - **角色只读**role == 'readonly')→ 隐藏(不显示)。
/// - **授权过期 >7 天**phase == 'readonly' | 'locked')→ 显示但置灰禁用,
/// 点击弹提示说明原因并引导去激活。
/// - **其它**normal / grace 宽限期)→ 原样显示且可点(宽限期后端仍允许写)。
///
/// 统一入口,避免在各页面散落 `if (!ref.watch(isReadonlyProvider))` 判断。
/// 后端 `middleware.ReadOnly()` 仍会对只读用户的写请求兜底返回 403
/// 本控件只负责「不让按钮出现」,二者配合:UI 不误导 + 后端不可绕过
/// 后端 `middleware.ReadOnly()` / `LicenseGuard()` 仍会对写请求兜底返回 403
/// 本控件只负责「不误导」:只读不显示、过期显示但不可点
///
/// 用法:
/// ```dart
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
/// ```
/// 列表 children 里可用 [hidden] 配合 collection-if 直接剔除分隔符
/// 列表 children 里可用 collection-if 配合 [isReadonly] 直接剔除分隔符
/// 过期置灰仍由内层 [WriteGuard] 负责:
/// ```dart
/// if (!WriteGuard.isReadonly(ref)) ...[button, const SizedBox(width: 8)]
/// if (!WriteGuard.isReadonly(ref)) ...[WriteGuard(child: button), const SizedBox(width: 8)]
/// ```
class WriteGuard extends ConsumerWidget {
final Widget child;
/// 只读时显示的占位控件,默认完全隐藏(不占位)。
/// 只读角色时显示的占位控件,默认完全隐藏(不占位)。
final Widget placeholder;
const WriteGuard({
@@ -29,11 +37,58 @@ class WriteGuard extends ConsumerWidget {
this.placeholder = const SizedBox.shrink(),
});
/// 供需要在 collection-if / 复合条件中判断的场景直接调用。
/// 当前是否只读角色(写按钮应**隐藏**)。供 collection-if / 复合条件直接调用。
static bool isReadonly(WidgetRef ref) => ref.watch(isReadonlyProvider);
/// 当前是否因授权过期 >7 天而应**禁用**写操作(phase readonly/locked)。
static bool licenseBlocked(WidgetRef ref) {
final lic = ref.watch(licenseProvider).valueOrNull;
return lic != null && lic.isReadOnlyPhase;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(isReadonlyProvider) ? placeholder : child;
// 1) 角色只读:隐藏
if (ref.watch(isReadonlyProvider)) return placeholder;
// 2) 授权过期 >7 天:显示但置灰禁用 + 点击弹提示
final lic = ref.watch(licenseProvider).valueOrNull;
if (lic != null && lic.isReadOnlyPhase) {
return _DisabledByLicense(lic: lic, child: child);
}
// 3) normal / grace:原样可点
return child;
}
}
/// 授权过期态的写控件包装:视觉置灰 + 拦截点击并提示。
class _DisabledByLicense extends StatelessWidget {
final LicenseInfo lic;
final Widget child;
const _DisabledByLicense({required this.lic, required this.child});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 置灰且不响应子控件自身的点击
IgnorePointer(child: Opacity(opacity: 0.45, child: child)),
// 覆盖一层透明手势层:捕获点击弹出原因说明
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger
?..clearSnackBars()
..showSnackBar(
SnackBar(content: Text(LicenseCopy.writeBlockedToast(lic))),
);
},
),
),
],
);
}
}