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
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../core/theme/app_theme.dart';
import 'write_guard.dart';
/// 入/出库单行内操作按钮,列表表格与移动端卡片、入库与出库两屏共用。
///
/// 此前 stock_in / stock_out 各自逐字重复了「详情/打印/结清/修改/删除/提交/通过/拒绝」
/// 的状态门控(draft/pending/approved)与 WriteGuard 包裹脚手架,仅单据类型、路由、
/// 打印函数与回调不同。这里把**相同的结构**收敛到单一来源,差异通过回调注入:
///
/// - [afterPrint]:紧随「打印」之后的附加按钮(入库的「打标签」;出库传空)。
/// - 各 `on*` 回调由调用方绑定到对应单据的确认/跳转逻辑。
List<Widget> buildOrderRowActions({
required bool readonly,
required String status,
required int orderId,
required VoidCallback onDetail,
required VoidCallback onPrint,
required VoidCallback onSettle,
required VoidCallback onEdit,
required VoidCallback onDelete,
required VoidCallback onSubmit,
required VoidCallback onApprove,
required VoidCallback onReject,
List<Widget> afterPrint = const [],
}) {
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
TextButton(
key: key,
onPressed: onPressed,
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
);
return [
btn('详情', AppTheme.primary, onDetail),
btn('打印', AppTheme.primary, onPrint),
...afterPrint,
if (!readonly && status == 'approved')
WriteGuard(child: btn('结清', AppTheme.accent, onSettle)),
if (!readonly && status == 'draft') ...[
WriteGuard(child: btn('修改', AppTheme.primary, onEdit)),
WriteGuard(child: btn('删除', AppTheme.danger, onDelete)),
WriteGuard(child: btn('提交', AppTheme.primary, onSubmit)),
],
if (!readonly && status == 'pending') ...[
WriteGuard(
child: btn('通过', AppTheme.success, onApprove,
key: Key('btn_approve_$orderId'))),
WriteGuard(
child: btn('拒绝', AppTheme.danger, onReject,
key: Key('btn_reject_$orderId'))),
],
];
}
+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))),
);
},
),
),
],
);
}
}