From 196901b6d373ef38af4d3f34b733fe3f12db129f Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 17 Jun 2026 00:42:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=8F=AA=E8=AF=BB=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E9=9A=90=E8=97=8F=E6=89=80=E6=9C=89=E5=86=99=E6=8C=89?= =?UTF-8?q?=E9=92=AE=EF=BC=88WriteGuard=20=E7=BB=9F=E4=B8=80=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=20+=20=E9=A1=B6=E6=A0=8F=E5=8F=AA=E8=AF=BB=E6=A0=87?= =?UTF-8?q?=E8=AF=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 isReadonlyProvider(role == 'readonly')+ WriteGuard 控件,统一隐藏写控件 - 入库/出库/库存/商品/基础数据/往来单位/财务/设置 各屏:只读时隐藏 新增/编辑/删除/审核/提交/驳回/结清/盘点/导入/激活/用户管理 等写操作 - 顶栏显示「只读」标识,让只读用户明确当前权限 - 后端 middleware.ReadOnly() 仍兜底 403,UI 隐藏 + 后端拦截双保险 修正:此前误判为后端漏洞/部署 bug,实为前端写按钮未对只读隐藏。 后端经验证正确拦截只读写操作(线上 stock-in/product-options 均 403)。 121 测试通过,analyze 无 error。 Co-Authored-By: Claude Opus 4.8 --- client/lib/core/auth/auth_state.dart | 8 ++ .../lib/screens/finance/finance_screen.dart | 12 ++- .../inventory/inventory_list_screen.dart | 27 ++++--- .../lib/screens/partners/partners_screen.dart | 77 ++++++++++--------- .../products/product_detail_screen.dart | 42 ++++++---- .../lib/screens/products/products_screen.dart | 41 ++++++---- .../lib/screens/settings/settings_screen.dart | 55 ++++++++----- client/lib/screens/shell/app_shell.dart | 24 ++++++ .../stock_in/stock_in_list_screen.dart | 10 ++- .../stock_out/stock_out_list_screen.dart | 10 ++- client/lib/widgets/write_guard.dart | 39 ++++++++++ 11 files changed, 233 insertions(+), 112 deletions(-) create mode 100644 client/lib/widgets/write_guard.dart diff --git a/client/lib/core/auth/auth_state.dart b/client/lib/core/auth/auth_state.dart index bcb0b6a..6170989 100644 --- a/client/lib/core/auth/auth_state.dart +++ b/client/lib/core/auth/auth_state.dart @@ -126,3 +126,11 @@ class AuthNotifier extends StateNotifier { final authStateProvider = StateNotifierProvider( (ref) => AuthNotifier(), ); + +/// 当前登录用户是否为只读角色(role == 'readonly')。 +/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮, +/// 后端亦有 middleware.ReadOnly() 兜底返回 403。 +final isReadonlyProvider = Provider((ref) { + final role = ref.watch(authStateProvider.select((s) => s.user?.role)); + return role == 'readonly'; +}); diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 5fcdbf8..8a8290a 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -11,6 +11,7 @@ import '../../widgets/page_scaffold.dart'; import '../../providers/connectivity_provider.dart'; import '../../core/utils/export_util.dart'; import '../../repositories/finance_repository.dart'; +import '../../core/auth/auth_state.dart'; class FinanceScreen extends ConsumerWidget { const FinanceScreen({super.key}); @@ -160,7 +161,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)), if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark), ], - actions: canClose + actions: (canClose && !ref.watch(isReadonlyProvider)) ? [ TextButton( onPressed: () => _closeRecord(r), @@ -304,7 +305,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), )); case 'actions': - if ((r.type == 'payable' || r.type == 'receivable') && r.status == 'open') { + if ((r.type == 'payable' || r.type == 'receivable') && + r.status == 'open' && + !ref.watch(isReadonlyProvider)) { return DataCell(TextButton( onPressed: () => _closeRecord(r), child: const Text('结清', @@ -405,13 +408,14 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { }, toolbar: Row( children: [ - if (addLabel != null) + if (addLabel != null && !ref.watch(isReadonlyProvider)) ElevatedButton.icon( onPressed: _showAddDialog, icon: const Icon(Icons.add, size: 16), label: Text(addLabel), ), - if (addLabel != null) const SizedBox(width: 8), + if (addLabel != null && !ref.watch(isReadonlyProvider)) + const SizedBox(width: 8), OutlinedButton.icon( onPressed: () { final tabName = widget.typeFilter.isEmpty diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index 4fdcc8a..a98c877 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -292,7 +292,9 @@ class _InventoryListScreenState extends ConsumerState { message: item.remark.isEmpty ? '' : item.remark, waitDuration: const Duration(milliseconds: 300), child: GestureDetector( - onTap: () => _editRemark(context, item), + onTap: ref.watch(isReadonlyProvider) + ? null + : () => _editRemark(context, item), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -358,10 +360,11 @@ class _InventoryListScreenState extends ConsumerState { MobileCardField('供应商', item.supplierName), ], actions: [ - TextButton( - onPressed: () => _editRemark(context, item), - child: const Text('备注', style: TextStyle(fontSize: 13)), - ), + if (!ref.watch(isReadonlyProvider)) + TextButton( + onPressed: () => _editRemark(context, item), + child: const Text('备注', style: TextStyle(fontSize: 13)), + ), if (item.productId != null) TextButton( onPressed: () => _printLabel(context, item), @@ -551,12 +554,14 @@ class _InventoryListScreenState extends ConsumerState { ref.read(inventoryListProvider.notifier).setPageSize(s), toolbar: Row( children: [ - OutlinedButton.icon( - onPressed: () => context.go('/inventory/check'), - icon: const Icon(Icons.fact_check, size: 16), - label: const Text('发起盘点'), - ), - const SizedBox(width: 8), + if (!ref.watch(isReadonlyProvider)) ...[ + OutlinedButton.icon( + onPressed: () => context.go('/inventory/check'), + icon: const Icon(Icons.fact_check, size: 16), + label: const Text('发起盘点'), + ), + const SizedBox(width: 8), + ], OutlinedButton.icon( onPressed: () => exportExcel( filename: '库存查询', diff --git a/client/lib/screens/partners/partners_screen.dart b/client/lib/screens/partners/partners_screen.dart index 4e128aa..7ae165e 100644 --- a/client/lib/screens/partners/partners_screen.dart +++ b/client/lib/screens/partners/partners_screen.dart @@ -11,6 +11,7 @@ import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/page_scaffold.dart'; import '../../core/utils/export_util.dart'; +import '../../core/auth/auth_state.dart'; class PartnersScreen extends ConsumerStatefulWidget { const PartnersScreen({super.key}); @@ -166,19 +167,21 @@ class _PartnersScreenState extends ConsumerState { if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone), if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address), ], - actions: [ - TextButton( - key: Key('btn_edit_${p.id}'), - onPressed: () => onEdit(p), - child: const Text('编辑', style: TextStyle(fontSize: 13)), - ), - TextButton( - key: Key('btn_delete_${p.id}'), - onPressed: () => onDelete(p), - child: const Text('删除', - style: TextStyle(fontSize: 13, color: AppTheme.danger)), - ), - ], + actions: ref.watch(isReadonlyProvider) + ? const [] + : [ + TextButton( + key: Key('btn_edit_${p.id}'), + onPressed: () => onEdit(p), + child: const Text('编辑', style: TextStyle(fontSize: 13)), + ), + TextButton( + key: Key('btn_delete_${p.id}'), + onPressed: () => onDelete(p), + child: const Text('删除', + style: TextStyle(fontSize: 13, color: AppTheme.danger)), + ), + ], ); } @@ -191,12 +194,14 @@ class _PartnersScreenState extends ConsumerState { mobileCards: partners.map(partnerCard).toList(), toolbar: Row( children: [ - ElevatedButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.add, size: 16), - label: Text(isSupplier ? '新建' : '新建'), - ), - const SizedBox(width: 8), + if (!ref.watch(isReadonlyProvider)) ...[ + ElevatedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 16), + label: Text(isSupplier ? '新建' : '新建'), + ), + const SizedBox(width: 8), + ], OutlinedButton.icon( onPressed: () => exportExcel( filename: isSupplier ? '供应商列表' : '客户列表', @@ -280,22 +285,24 @@ class _PartnersScreenState extends ConsumerState { )), DataCell(Row( mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - key: Key('btn_edit_${p.id}'), - onPressed: () => onEdit(p), - child: const Text('编辑', - style: TextStyle(fontSize: 12)), - ), - TextButton( - key: Key('btn_delete_${p.id}'), - onPressed: () => onDelete(p), - child: const Text('删除', - style: TextStyle( - fontSize: 12, - color: AppTheme.danger)), - ), - ], + children: ref.watch(isReadonlyProvider) + ? const [] + : [ + TextButton( + key: Key('btn_edit_${p.id}'), + onPressed: () => onEdit(p), + child: const Text('编辑', + style: TextStyle(fontSize: 12)), + ), + TextButton( + key: Key('btn_delete_${p.id}'), + onPressed: () => onDelete(p), + child: const Text('删除', + style: TextStyle( + fontSize: 12, + color: AppTheme.danger)), + ), + ], )), ], )) diff --git a/client/lib/screens/products/product_detail_screen.dart b/client/lib/screens/products/product_detail_screen.dart index 0f68933..27affb7 100644 --- a/client/lib/screens/products/product_detail_screen.dart +++ b/client/lib/screens/products/product_detail_screen.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/config/app_config.dart'; import '../../core/utils/print_util.dart'; import '../../widgets/label_preview_dialog.dart'; +import '../../widgets/write_guard.dart'; import '../../core/theme/app_theme.dart'; import '../../models/product.dart'; import '../../models/product_image.dart'; @@ -313,7 +314,10 @@ class _ProductDetailScreenState extends ConsumerState { onDelete: () => _deleteImage(img), )), if (p.images.length < 5) - _UploadButton(uploading: _uploading, onTap: _pickAndUpload), + WriteGuard( + child: + _UploadButton(uploading: _uploading, onTap: _pickAndUpload), + ), ], ), ), @@ -470,20 +474,24 @@ class _ProductDetailScreenState extends ConsumerState { fontSize: 14, fontWeight: FontWeight.w600)), const Spacer(), if (_descChanged) - _savingDesc - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) - : ElevatedButton( - onPressed: _saveDesc, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap), - child: const Text('保存', style: TextStyle(fontSize: 13)), - ), + WriteGuard( + child: _savingDesc + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : ElevatedButton( + onPressed: _saveDesc, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap), + child: + const Text('保存', style: TextStyle(fontSize: 13)), + ), + ), ], ), const SizedBox(height: 12), @@ -586,7 +594,8 @@ class _ImageThumbnail extends StatelessWidget { Positioned( top: 4, right: 4, - child: GestureDetector( + child: WriteGuard( + child: GestureDetector( onTap: onDelete, child: Container( width: 20, @@ -599,6 +608,7 @@ class _ImageThumbnail extends StatelessWidget { const Icon(Icons.close, size: 14, color: Colors.white), ), ), + ), ), ], ), diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index a412e8c..7a5dc3e 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -10,6 +10,7 @@ import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/page_scaffold.dart'; import '../../core/utils/export_util.dart'; +import '../../core/auth/auth_state.dart'; class ProductsScreen extends ConsumerStatefulWidget { const ProductsScreen({super.key}); @@ -354,14 +355,17 @@ class _ProductsScreenState extends ConsumerState { required VoidCallback onAdd, required VoidCallback onExport, }) { + final readonly = ref.watch(isReadonlyProvider); return Row( children: [ - ElevatedButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.add, size: 16), - label: const Text('新建'), - ), - const SizedBox(width: 8), + if (!readonly) ...[ + ElevatedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 16), + label: const Text('新建'), + ), + const SizedBox(width: 8), + ], OutlinedButton.icon( onPressed: onExport, icon: const Icon(Icons.download, size: 16), @@ -416,21 +420,24 @@ class _ProductsScreenState extends ConsumerState { MobileCardField('单品数量', '$quantity'), if (remark?.isNotEmpty == true) MobileCardField('备注', remark), ], - actions: [ - TextButton( - onPressed: onEdit, - child: const Text('编辑', style: TextStyle(fontSize: 13)), - ), - TextButton( - onPressed: onDelete, - child: const Text('删除', - style: TextStyle(fontSize: 13, color: AppTheme.danger)), - ), - ], + actions: ref.watch(isReadonlyProvider) + ? const [] + : [ + TextButton( + onPressed: onEdit, + child: const Text('编辑', style: TextStyle(fontSize: 13)), + ), + TextButton( + onPressed: onDelete, + child: const Text('删除', + style: TextStyle(fontSize: 13, color: AppTheme.danger)), + ), + ], ); } Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) { + if (ref.watch(isReadonlyProvider)) return const SizedBox.shrink(); return Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index b2f7d7d..bb95dd9 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -218,11 +218,12 @@ class _SettingsScreenState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - ElevatedButton.icon( - onPressed: () => _showAddUserDialog(context), - icon: const Icon(Icons.person_add, size: 16), - label: const Text('新增用户'), - ), + if (!ref.watch(isReadonlyProvider)) + ElevatedButton.icon( + onPressed: () => _showAddUserDialog(context), + icon: const Icon(Icons.person_add, size: 16), + label: const Text('新增用户'), + ), ], ), ), @@ -292,7 +293,9 @@ class _SettingsScreenState extends ConsumerState { )), DataCell(Row( mainAxisSize: MainAxisSize.min, - children: [ + children: ref.watch(isReadonlyProvider) + ? const [] + : [ TextButton( onPressed: () => _showEditUserDialog(context, u), @@ -454,16 +457,18 @@ class _SettingsScreenState extends ConsumerState { style: const TextStyle(fontSize: 13), ), ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _isActivating ? null : _activateLicense, - child: _isActivating - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('激活'), - ), + if (!ref.watch(isReadonlyProvider)) ...[ + const SizedBox(width: 8), + ElevatedButton( + onPressed: _isActivating ? null : _activateLicense, + child: _isActivating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('激活'), + ), + ], ], ), ], @@ -614,11 +619,13 @@ class _SettingsScreenState extends ConsumerState { style: const TextStyle( fontFamily: 'monospace', fontSize: 12))), DataCell(Text('${r.currentNo}')), - DataCell(TextButton( - onPressed: () => - _showNumberRuleDialog(context, r), - child: const Text('编辑', - style: TextStyle(fontSize: 12)))), + DataCell(ref.watch(isReadonlyProvider) + ? const SizedBox() + : TextButton( + onPressed: () => + _showNumberRuleDialog(context, r), + child: const Text('编辑', + style: TextStyle(fontSize: 12)))), ])) .toList(), ), @@ -797,6 +804,12 @@ class _SettingsScreenState extends ConsumerState { // ── 数据导入 Tab ────────────────────────────────────────── Widget _buildImportTab() { + if (ref.watch(isReadonlyProvider)) { + return const Center( + child: Text('只读账号无导入权限', + style: TextStyle(color: AppTheme.textSecondary)), + ); + } final currentUser = ref.watch(authStateProvider).user; final isSuperAdmin = currentUser?.role == 'superadmin'; return _BatchImportWidget(isSuperAdmin: isSuperAdmin); diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index a39cff4..db56d4e 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -210,6 +210,30 @@ class _AppShellState extends ConsumerState { ), const SizedBox(width: 4), _ShopButton(user: user, version: appVersion), + if (ref.watch(isReadonlyProvider)) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.visibility_outlined, + size: 13, color: Colors.white), + SizedBox(width: 4), + Text('只读', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600)), + ], + ), + ), + ], const Spacer(), if (user != null) ...[ // 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤 diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index e51b78f..a008c6c 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -22,6 +22,7 @@ import '../../providers/product_provider.dart' show productRepositoryProvider; import '../../repositories/product_repository.dart'; import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/shop_provider.dart' show shopInfoProvider; +import '../../core/auth/auth_state.dart'; class StockInListScreen extends ConsumerStatefulWidget { const StockInListScreen({super.key}); @@ -280,7 +281,7 @@ class _StockInListScreenState extends ConsumerState { ref.read(stockInListProvider.notifier).setPageSize(s), toolbar: Row( children: [ - if (showNewButton) + if (showNewButton && !ref.watch(isReadonlyProvider)) ElevatedButton.icon( onPressed: () => context.go('/stock-in/new'), icon: const Icon(Icons.add, size: 16), @@ -344,6 +345,7 @@ class _StockInListScreenState extends ConsumerState { /// 操作按钮列表,表格与移动端卡片共用。 List _orderActions(BuildContext context, StockInOrder o) { + final readonly = ref.watch(isReadonlyProvider); return [ TextButton( onPressed: () => _showDetail(context, o.id), @@ -390,13 +392,13 @@ class _StockInListScreenState extends ConsumerState { child: const Text('打标签', style: TextStyle(fontSize: 12, color: AppTheme.primary)), ), - if (o.status == 'approved') + if (!readonly && o.status == 'approved') TextButton( onPressed: () => _confirmSettle(context, o.id, 'stock_in'), child: const Text('结清', style: TextStyle(fontSize: 12, color: AppTheme.accent)), ), - if (o.status == 'draft') ...[ + if (!readonly && o.status == 'draft') ...[ TextButton( onPressed: () => context.go('/stock-in/edit/${o.id}'), child: const Text('修改', @@ -413,7 +415,7 @@ class _StockInListScreenState extends ConsumerState { style: TextStyle(fontSize: 12, color: AppTheme.primary)), ), ], - if (o.status == 'pending') ...[ + if (!readonly && o.status == 'pending') ...[ TextButton( key: Key('btn_approve_${o.id}'), onPressed: () => _confirmApprove(context, o), diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 457410b..b2d7089 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -20,6 +20,7 @@ import '../../providers/inventory_provider.dart'; import '../../providers/tab_state_provider.dart'; import '../../providers/product_provider.dart'; import '../../providers/finance_provider.dart' show financeRepositoryProvider; +import '../../core/auth/auth_state.dart'; class StockOutListScreen extends ConsumerStatefulWidget { const StockOutListScreen({super.key}); @@ -286,7 +287,7 @@ class _StockOutListScreenState extends ConsumerState { ref.read(stockOutListProvider.notifier).setPageSize(s), toolbar: Row( children: [ - if (showNewButton) + if (showNewButton && !ref.watch(isReadonlyProvider)) ElevatedButton.icon( onPressed: () => context.go('/stock-out/new'), icon: const Icon(Icons.add, size: 16), @@ -350,6 +351,7 @@ class _StockOutListScreenState extends ConsumerState { /// 操作按钮列表,表格与移动端卡片共用。 List _orderActions(BuildContext context, StockOutOrder o) { + final readonly = ref.watch(isReadonlyProvider); return [ TextButton( onPressed: () => _showDetail(context, o.id), @@ -366,13 +368,13 @@ class _StockOutListScreenState extends ConsumerState { child: const Text('打印', style: TextStyle(fontSize: 12, color: AppTheme.primary)), ), - if (o.status == 'approved') + if (!readonly && o.status == 'approved') TextButton( onPressed: () => _confirmSettle(context, o.id, 'stock_out'), child: const Text('结清', style: TextStyle(fontSize: 12, color: AppTheme.accent)), ), - if (o.status == 'draft') ...[ + if (!readonly && o.status == 'draft') ...[ TextButton( onPressed: () => context.go('/stock-out/edit/${o.id}'), child: const Text('修改', @@ -389,7 +391,7 @@ class _StockOutListScreenState extends ConsumerState { style: TextStyle(fontSize: 12, color: AppTheme.primary)), ), ], - if (o.status == 'pending') ...[ + if (!readonly && o.status == 'pending') ...[ TextButton( key: Key('btn_approve_${o.id}'), onPressed: () => _confirmApprove(context, o), diff --git a/client/lib/widgets/write_guard.dart b/client/lib/widgets/write_guard.dart new file mode 100644 index 0000000..1d11ccd --- /dev/null +++ b/client/lib/widgets/write_guard.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/auth/auth_state.dart'; + +/// 写操作守卫:当前登录用户为只读角色(role == 'readonly')时, +/// 隐藏被包裹的写操作控件(新增/编辑/删除/审核/提交/结清/导入…)。 +/// +/// 统一入口,避免在各页面散落 `if (!ref.watch(isReadonlyProvider))` 判断。 +/// 后端 `middleware.ReadOnly()` 仍会对只读用户的写请求兜底返回 403, +/// 本控件只负责「不让按钮出现」,二者配合:UI 不误导 + 后端不可绕过。 +/// +/// 用法: +/// ```dart +/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建'))) +/// ``` +/// 列表 children 里可用 [hidden] 配合 collection-if 直接剔除分隔符: +/// ```dart +/// if (!WriteGuard.isReadonly(ref)) ...[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); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref.watch(isReadonlyProvider) ? placeholder : child; + } +}