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
@@ -24,6 +24,7 @@ import '../../repositories/product_repository.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart';
import '../../widgets/order_row_actions.dart';
class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key});
@@ -306,18 +307,20 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
);
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
? ElevatedButton.icon(
onPressed: () => context.go('/stock-in/new'),
icon: const Icon(Icons.add, size: 16),
label: Text(isMobile ? '新建' : '新建入库审核单'),
style: isMobile
? ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
: null,
? WriteGuard(
child: ElevatedButton.icon(
onPressed: () => context.go('/stock-in/new'),
icon: const Icon(Icons.add, size: 16),
label: Text(isMobile ? '新建' : '新建入库审核单'),
style: isMobile
? ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
: null,
),
)
: null;
@@ -417,93 +420,59 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
);
}
/// 操作按钮列表,表格与移动端卡片共用。
/// 操作按钮列表,表格与移动端卡片共用。结构见 [buildOrderRowActions]
/// 入库特有的「打标签」通过 afterPrint 注入。
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
final readonly = WriteGuard.isReadonly(ref);
return [
TextButton(
onPressed: () => _showDetail(context, o.id),
child: const Text('详情',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
TextButton(
onPressed: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (context.mounted) {
await safePrint(context, () => printStockInOrder(order));
}
},
child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
TextButton(
onPressed: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (!context.mounted) return;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
final labels = order.items
.map((item) => LabelData(
productId: item.productId,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
))
.toList();
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(
labels: labels,
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
),
);
},
child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
if (!readonly && o.status == 'approved')
return buildOrderRowActions(
readonly: WriteGuard.isReadonly(ref),
status: o.status,
orderId: o.id,
onDetail: () => _showDetail(context, o.id),
onPrint: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (context.mounted) {
await safePrint(context, () => printStockInOrder(order));
}
},
afterPrint: [
TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
),
if (!readonly && o.status == 'draft') ...[
TextButton(
onPressed: () => context.go('/stock-in/edit/${o.id}'),
child: const Text('修改',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
TextButton(
onPressed: () => _confirmDelete(context, o),
child: const Text('删除',
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
),
TextButton(
onPressed: () => _confirmSubmit(context, o),
child: const Text('提交',
onPressed: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (!context.mounted) return;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
final labels = order.items
.map((item) => LabelData(
productId: item.productId,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
))
.toList();
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(
labels: labels,
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
),
);
},
child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
],
if (!readonly && o.status == 'pending') ...[
TextButton(
key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o),
child: const Text('通过',
style: TextStyle(fontSize: 12, color: AppTheme.success)),
),
TextButton(
key: Key('btn_reject_${o.id}'),
onPressed: () => _confirmReject(context, o),
child: const Text('拒绝',
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
),
],
];
onSettle: () => _confirmSettle(context, o.id, 'stock_in'),
onEdit: () => context.go('/stock-in/edit/${o.id}'),
onDelete: () => _confirmDelete(context, o),
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o),
);
}
/// 入库单:窄屏卡片