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
@@ -79,6 +79,33 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
}
/// 备注列展示:editable 时附带编辑图标(用于 WriteGuard 的可点子控件),
/// 否则纯文本(只读角色占位)。
Widget _remarkDisplay(Inventory item, {required bool editable}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.remark.isEmpty
? ''
: item.remark.length > 4
? '${item.remark.substring(0, 4)}'
: item.remark,
style: TextStyle(
color: item.remark.isEmpty
? AppTheme.textSecondary
: AppTheme.textPrimary,
),
),
if (editable) ...[
const SizedBox(width: 4),
const Icon(Icons.edit_outlined,
size: 12, color: AppTheme.textSecondary),
],
],
);
}
Future<void> _editRemark(BuildContext context, Inventory item) async {
final ctrl = TextEditingController(text: item.remark);
final saved = await showDialog<String>(
@@ -304,28 +331,13 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
'remark' => DataCell(Tooltip(
message: item.remark.isEmpty ? '' : item.remark,
waitDuration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: WriteGuard.isReadonly(ref)
? null
: () => _editRemark(context, item),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.remark.isEmpty
? ''
: item.remark.length > 4
? '${item.remark.substring(0, 4)}'
: item.remark,
style: TextStyle(
color: item.remark.isEmpty
? AppTheme.textSecondary
: AppTheme.textPrimary,
),
),
const SizedBox(width: 4),
const Icon(Icons.edit_outlined, size: 12, color: AppTheme.textSecondary),
],
// 内联编辑入口统一交给 WriteGuard:只读角色显示纯文本(无编辑图标),
// 授权过期则由 WriteGuard 自动置灰并在点击时弹提示——不再手搓三元 + toast。
child: WriteGuard(
placeholder: _remarkDisplay(item, editable: false),
child: GestureDetector(
onTap: () => _editRemark(context, item),
child: _remarkDisplay(item, editable: true),
),
))),
'status' => DataCell(_InventoryStatusBadge(item)),
@@ -374,9 +386,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
],
actions: [
if (!WriteGuard.isReadonly(ref))
TextButton(
onPressed: () => _editRemark(context, item),
child: const Text('备注', style: TextStyle(fontSize: 13)),
WriteGuard(
child: TextButton(
onPressed: () => _editRemark(context, item),
child: const Text('备注', style: TextStyle(fontSize: 13)),
),
),
if (item.productId != null)
TextButton(
@@ -622,16 +636,19 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Row(
children: [
if (canCheck)
OutlinedButton.icon(
onPressed: () => context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('盘点'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
WriteGuard(
child: OutlinedButton.icon(
onPressed: () =>
context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('盘点'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
),
),
const Spacer(),
@@ -671,10 +688,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
SizedBox(width: 220, child: searchField),
const SizedBox(width: 12),
if (canCheck) ...[
OutlinedButton.icon(
onPressed: () => context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('发起盘点'),
WriteGuard(
child: OutlinedButton.icon(
onPressed: () => context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('发起盘点'),
),
),
const SizedBox(width: 8),
],