32bd64d676
- 心跳读取 /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>
95 lines
3.5 KiB
Dart
95 lines
3.5 KiB
Dart
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')→ 隐藏(不显示)。
|
||
/// - **授权过期 >7 天**(phase == 'readonly' | 'locked')→ 显示但置灰禁用,
|
||
/// 点击弹提示说明原因并引导去激活。
|
||
/// - **其它**(normal / grace 宽限期)→ 原样显示且可点(宽限期后端仍允许写)。
|
||
///
|
||
/// 统一入口,避免在各页面散落 `if (!ref.watch(isReadonlyProvider))` 判断。
|
||
/// 后端 `middleware.ReadOnly()` / `LicenseGuard()` 仍会对写请求兜底返回 403,
|
||
/// 本控件只负责「不误导」:只读不显示、过期显示但不可点。
|
||
///
|
||
/// 用法:
|
||
/// ```dart
|
||
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
|
||
/// ```
|
||
/// 列表 children 里可用 collection-if 配合 [isReadonly] 直接剔除分隔符;
|
||
/// 过期置灰仍由内层 [WriteGuard] 负责:
|
||
/// ```dart
|
||
/// if (!WriteGuard.isReadonly(ref)) ...[WriteGuard(child: button), const SizedBox(width: 8)]
|
||
/// ```
|
||
class WriteGuard extends ConsumerWidget {
|
||
final Widget child;
|
||
|
||
/// 只读角色时显示的占位控件,默认完全隐藏(不占位)。
|
||
final Widget placeholder;
|
||
|
||
const WriteGuard({
|
||
super.key,
|
||
required this.child,
|
||
this.placeholder = const SizedBox.shrink(),
|
||
});
|
||
|
||
/// 当前是否只读角色(写按钮应**隐藏**)。供 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) {
|
||
// 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))),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|