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:
@@ -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'))),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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))),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user