feat(client): 只读角色隐藏所有写按钮(WriteGuard 统一控件 + 顶栏只读标识)

- 新增 isReadonlyProvider(role == 'readonly')+ WriteGuard 控件,统一隐藏写控件
- 入库/出库/库存/商品/基础数据/往来单位/财务/设置 各屏:只读时隐藏
  新增/编辑/删除/审核/提交/驳回/结清/盘点/导入/激活/用户管理 等写操作
- 顶栏显示「只读」标识,让只读用户明确当前权限
- 后端 middleware.ReadOnly() 仍兜底 403,UI 隐藏 + 后端拦截双保险

修正:此前误判为后端漏洞/部署 bug,实为前端写按钮未对只读隐藏。
后端经验证正确拦截只读写操作(线上 stock-in/product-options 均 403)。

121 测试通过,analyze 无 error。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 00:42:40 +08:00
parent 53465ed704
commit 196901b6d3
11 changed files with 233 additions and 112 deletions
+8
View File
@@ -126,3 +126,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>( final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(), (ref) => AuthNotifier(),
); );
/// 当前登录用户是否为只读角色(role == 'readonly')。
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
final isReadonlyProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'readonly';
});
@@ -11,6 +11,7 @@ import '../../widgets/page_scaffold.dart';
import '../../providers/connectivity_provider.dart'; import '../../providers/connectivity_provider.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../repositories/finance_repository.dart'; import '../../repositories/finance_repository.dart';
import '../../core/auth/auth_state.dart';
class FinanceScreen extends ConsumerWidget { class FinanceScreen extends ConsumerWidget {
const FinanceScreen({super.key}); const FinanceScreen({super.key});
@@ -160,7 +161,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)), MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)),
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark), if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
], ],
actions: canClose actions: (canClose && !ref.watch(isReadonlyProvider))
? [ ? [
TextButton( TextButton(
onPressed: () => _closeRecord(r), onPressed: () => _closeRecord(r),
@@ -304,7 +305,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
)); ));
case 'actions': 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( return DataCell(TextButton(
onPressed: () => _closeRecord(r), onPressed: () => _closeRecord(r),
child: const Text('结清', child: const Text('结清',
@@ -405,13 +408,14 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
}, },
toolbar: Row( toolbar: Row(
children: [ children: [
if (addLabel != null) if (addLabel != null && !ref.watch(isReadonlyProvider))
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _showAddDialog, onPressed: _showAddDialog,
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
label: Text(addLabel), label: Text(addLabel),
), ),
if (addLabel != null) const SizedBox(width: 8), if (addLabel != null && !ref.watch(isReadonlyProvider))
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: () {
final tabName = widget.typeFilter.isEmpty final tabName = widget.typeFilter.isEmpty
@@ -292,7 +292,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
message: item.remark.isEmpty ? '' : item.remark, message: item.remark.isEmpty ? '' : item.remark,
waitDuration: const Duration(milliseconds: 300), waitDuration: const Duration(milliseconds: 300),
child: GestureDetector( child: GestureDetector(
onTap: () => _editRemark(context, item), onTap: ref.watch(isReadonlyProvider)
? null
: () => _editRemark(context, item),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -358,10 +360,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
MobileCardField('供应商', item.supplierName), MobileCardField('供应商', item.supplierName),
], ],
actions: [ actions: [
TextButton( if (!ref.watch(isReadonlyProvider))
onPressed: () => _editRemark(context, item), TextButton(
child: const Text('备注', style: TextStyle(fontSize: 13)), onPressed: () => _editRemark(context, item),
), child: const Text('备注', style: TextStyle(fontSize: 13)),
),
if (item.productId != null) if (item.productId != null)
TextButton( TextButton(
onPressed: () => _printLabel(context, item), onPressed: () => _printLabel(context, item),
@@ -551,12 +554,14 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ref.read(inventoryListProvider.notifier).setPageSize(s), ref.read(inventoryListProvider.notifier).setPageSize(s),
toolbar: Row( toolbar: Row(
children: [ children: [
OutlinedButton.icon( if (!ref.watch(isReadonlyProvider)) ...[
onPressed: () => context.go('/inventory/check'), OutlinedButton.icon(
icon: const Icon(Icons.fact_check, size: 16), onPressed: () => context.go('/inventory/check'),
label: const Text('发起盘点'), icon: const Icon(Icons.fact_check, size: 16),
), label: const Text('发起盘点'),
const SizedBox(width: 8), ),
const SizedBox(width: 8),
],
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => exportExcel( onPressed: () => exportExcel(
filename: '库存查询', filename: '库存查询',
@@ -11,6 +11,7 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart'; import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart'; import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../core/auth/auth_state.dart';
class PartnersScreen extends ConsumerStatefulWidget { class PartnersScreen extends ConsumerStatefulWidget {
const PartnersScreen({super.key}); const PartnersScreen({super.key});
@@ -166,19 +167,21 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone), if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone),
if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address), if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address),
], ],
actions: [ actions: ref.watch(isReadonlyProvider)
TextButton( ? const []
key: Key('btn_edit_${p.id}'), : [
onPressed: () => onEdit(p), TextButton(
child: const Text('编辑', style: TextStyle(fontSize: 13)), key: Key('btn_edit_${p.id}'),
), onPressed: () => onEdit(p),
TextButton( child: const Text('编辑', style: TextStyle(fontSize: 13)),
key: Key('btn_delete_${p.id}'), ),
onPressed: () => onDelete(p), TextButton(
child: const Text('删除', key: Key('btn_delete_${p.id}'),
style: TextStyle(fontSize: 13, color: AppTheme.danger)), onPressed: () => onDelete(p),
), child: const Text('删除',
], style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
); );
} }
@@ -191,12 +194,14 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
mobileCards: partners.map(partnerCard).toList(), mobileCards: partners.map(partnerCard).toList(),
toolbar: Row( toolbar: Row(
children: [ children: [
ElevatedButton.icon( if (!ref.watch(isReadonlyProvider)) ...[
onPressed: onAdd, ElevatedButton.icon(
icon: const Icon(Icons.add, size: 16), onPressed: onAdd,
label: Text(isSupplier ? '新建' : '新建'), icon: const Icon(Icons.add, size: 16),
), label: Text(isSupplier ? '新建' : '新建'),
const SizedBox(width: 8), ),
const SizedBox(width: 8),
],
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => exportExcel( onPressed: () => exportExcel(
filename: isSupplier ? '供应商列表' : '客户列表', filename: isSupplier ? '供应商列表' : '客户列表',
@@ -280,22 +285,24 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
)), )),
DataCell(Row( DataCell(Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: ref.watch(isReadonlyProvider)
TextButton( ? const []
key: Key('btn_edit_${p.id}'), : [
onPressed: () => onEdit(p), TextButton(
child: const Text('编辑', key: Key('btn_edit_${p.id}'),
style: TextStyle(fontSize: 12)), onPressed: () => onEdit(p),
), child: const Text('编辑',
TextButton( style: TextStyle(fontSize: 12)),
key: Key('btn_delete_${p.id}'), ),
onPressed: () => onDelete(p), TextButton(
child: const Text('删除', key: Key('btn_delete_${p.id}'),
style: TextStyle( onPressed: () => onDelete(p),
fontSize: 12, child: const Text('删除',
color: AppTheme.danger)), style: TextStyle(
), fontSize: 12,
], color: AppTheme.danger)),
),
],
)), )),
], ],
)) ))
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_config.dart'; import '../../core/config/app_config.dart';
import '../../core/utils/print_util.dart'; import '../../core/utils/print_util.dart';
import '../../widgets/label_preview_dialog.dart'; import '../../widgets/label_preview_dialog.dart';
import '../../widgets/write_guard.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../models/product.dart'; import '../../models/product.dart';
import '../../models/product_image.dart'; import '../../models/product_image.dart';
@@ -313,7 +314,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
onDelete: () => _deleteImage(img), onDelete: () => _deleteImage(img),
)), )),
if (p.images.length < 5) 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<ProductDetailScreen> {
fontSize: 14, fontWeight: FontWeight.w600)), fontSize: 14, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
if (_descChanged) if (_descChanged)
_savingDesc WriteGuard(
? const SizedBox( child: _savingDesc
width: 20, ? const SizedBox(
height: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2)) height: 20,
: ElevatedButton( child: CircularProgressIndicator(strokeWidth: 2))
onPressed: _saveDesc, : ElevatedButton(
style: ElevatedButton.styleFrom( onPressed: _saveDesc,
padding: const EdgeInsets.symmetric( style: ElevatedButton.styleFrom(
horizontal: 16, vertical: 6), padding: const EdgeInsets.symmetric(
minimumSize: Size.zero, horizontal: 16, vertical: 6),
tapTargetSize: MaterialTapTargetSize.shrinkWrap), minimumSize: Size.zero,
child: const Text('保存', style: TextStyle(fontSize: 13)), tapTargetSize:
), MaterialTapTargetSize.shrinkWrap),
child:
const Text('保存', style: TextStyle(fontSize: 13)),
),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -586,7 +594,8 @@ class _ImageThumbnail extends StatelessWidget {
Positioned( Positioned(
top: 4, top: 4,
right: 4, right: 4,
child: GestureDetector( child: WriteGuard(
child: GestureDetector(
onTap: onDelete, onTap: onDelete,
child: Container( child: Container(
width: 20, width: 20,
@@ -599,6 +608,7 @@ class _ImageThumbnail extends StatelessWidget {
const Icon(Icons.close, size: 14, color: Colors.white), const Icon(Icons.close, size: 14, color: Colors.white),
), ),
), ),
),
), ),
], ],
), ),
@@ -10,6 +10,7 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart'; import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart'; import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../core/auth/auth_state.dart';
class ProductsScreen extends ConsumerStatefulWidget { class ProductsScreen extends ConsumerStatefulWidget {
const ProductsScreen({super.key}); const ProductsScreen({super.key});
@@ -354,14 +355,17 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
required VoidCallback onAdd, required VoidCallback onAdd,
required VoidCallback onExport, required VoidCallback onExport,
}) { }) {
final readonly = ref.watch(isReadonlyProvider);
return Row( return Row(
children: [ children: [
ElevatedButton.icon( if (!readonly) ...[
onPressed: onAdd, ElevatedButton.icon(
icon: const Icon(Icons.add, size: 16), onPressed: onAdd,
label: const Text('新建'), icon: const Icon(Icons.add, size: 16),
), label: const Text('新建'),
const SizedBox(width: 8), ),
const SizedBox(width: 8),
],
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onExport, onPressed: onExport,
icon: const Icon(Icons.download, size: 16), icon: const Icon(Icons.download, size: 16),
@@ -416,21 +420,24 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
MobileCardField('单品数量', '$quantity'), MobileCardField('单品数量', '$quantity'),
if (remark?.isNotEmpty == true) MobileCardField('备注', remark), if (remark?.isNotEmpty == true) MobileCardField('备注', remark),
], ],
actions: [ actions: ref.watch(isReadonlyProvider)
TextButton( ? const []
onPressed: onEdit, : [
child: const Text('编辑', style: TextStyle(fontSize: 13)), TextButton(
), onPressed: onEdit,
TextButton( child: const Text('编辑', style: TextStyle(fontSize: 13)),
onPressed: onDelete, ),
child: const Text('删除', TextButton(
style: TextStyle(fontSize: 13, color: AppTheme.danger)), onPressed: onDelete,
), child: const Text('删除',
], style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
); );
} }
Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) { Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) {
if (ref.watch(isReadonlyProvider)) return const SizedBox.shrink();
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -218,11 +218,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
ElevatedButton.icon( if (!ref.watch(isReadonlyProvider))
onPressed: () => _showAddUserDialog(context), ElevatedButton.icon(
icon: const Icon(Icons.person_add, size: 16), onPressed: () => _showAddUserDialog(context),
label: const Text('新增用户'), icon: const Icon(Icons.person_add, size: 16),
), label: const Text('新增用户'),
),
], ],
), ),
), ),
@@ -292,7 +293,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
)), )),
DataCell(Row( DataCell(Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: ref.watch(isReadonlyProvider)
? const []
: [
TextButton( TextButton(
onPressed: () => onPressed: () =>
_showEditUserDialog(context, u), _showEditUserDialog(context, u),
@@ -454,16 +457,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ),
const SizedBox(width: 8), if (!ref.watch(isReadonlyProvider)) ...[
ElevatedButton( const SizedBox(width: 8),
onPressed: _isActivating ? null : _activateLicense, ElevatedButton(
child: _isActivating onPressed: _isActivating ? null : _activateLicense,
? const SizedBox( child: _isActivating
width: 16, ? const SizedBox(
height: 16, width: 16,
child: CircularProgressIndicator(strokeWidth: 2)) height: 16,
: const Text('激活'), child: CircularProgressIndicator(strokeWidth: 2))
), : const Text('激活'),
),
],
], ],
), ),
], ],
@@ -614,11 +619,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: const TextStyle( style: const TextStyle(
fontFamily: 'monospace', fontSize: 12))), fontFamily: 'monospace', fontSize: 12))),
DataCell(Text('${r.currentNo}')), DataCell(Text('${r.currentNo}')),
DataCell(TextButton( DataCell(ref.watch(isReadonlyProvider)
onPressed: () => ? const SizedBox()
_showNumberRuleDialog(context, r), : TextButton(
child: const Text('编辑', onPressed: () =>
style: TextStyle(fontSize: 12)))), _showNumberRuleDialog(context, r),
child: const Text('编辑',
style: TextStyle(fontSize: 12)))),
])) ]))
.toList(), .toList(),
), ),
@@ -797,6 +804,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
// ── 数据导入 Tab ────────────────────────────────────────── // ── 数据导入 Tab ──────────────────────────────────────────
Widget _buildImportTab() { Widget _buildImportTab() {
if (ref.watch(isReadonlyProvider)) {
return const Center(
child: Text('只读账号无导入权限',
style: TextStyle(color: AppTheme.textSecondary)),
);
}
final currentUser = ref.watch(authStateProvider).user; final currentUser = ref.watch(authStateProvider).user;
final isSuperAdmin = currentUser?.role == 'superadmin'; final isSuperAdmin = currentUser?.role == 'superadmin';
return _BatchImportWidget(isSuperAdmin: isSuperAdmin); return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
+24
View File
@@ -210,6 +210,30 @@ class _AppShellState extends ConsumerState<AppShell> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
_ShopButton(user: user, version: appVersion), _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(), const Spacer(),
if (user != null) ...[ if (user != null) ...[
// 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤 // 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤
@@ -22,6 +22,7 @@ import '../../providers/product_provider.dart' show productRepositoryProvider;
import '../../repositories/product_repository.dart'; import '../../repositories/product_repository.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../providers/shop_provider.dart' show shopInfoProvider; import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../core/auth/auth_state.dart';
class StockInListScreen extends ConsumerStatefulWidget { class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key}); const StockInListScreen({super.key});
@@ -280,7 +281,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
ref.read(stockInListProvider.notifier).setPageSize(s), ref.read(stockInListProvider.notifier).setPageSize(s),
toolbar: Row( toolbar: Row(
children: [ children: [
if (showNewButton) if (showNewButton && !ref.watch(isReadonlyProvider))
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => context.go('/stock-in/new'), onPressed: () => context.go('/stock-in/new'),
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
@@ -344,6 +345,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
/// 操作按钮列表,表格与移动端卡片共用。 /// 操作按钮列表,表格与移动端卡片共用。
List<Widget> _orderActions(BuildContext context, StockInOrder o) { List<Widget> _orderActions(BuildContext context, StockInOrder o) {
final readonly = ref.watch(isReadonlyProvider);
return [ return [
TextButton( TextButton(
onPressed: () => _showDetail(context, o.id), onPressed: () => _showDetail(context, o.id),
@@ -390,13 +392,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
child: const Text('打标签', child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)), style: TextStyle(fontSize: 12, color: AppTheme.primary)),
), ),
if (o.status == 'approved') if (!readonly && o.status == 'approved')
TextButton( TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_in'), onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
child: const Text('结清', child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)), style: TextStyle(fontSize: 12, color: AppTheme.accent)),
), ),
if (o.status == 'draft') ...[ if (!readonly && o.status == 'draft') ...[
TextButton( TextButton(
onPressed: () => context.go('/stock-in/edit/${o.id}'), onPressed: () => context.go('/stock-in/edit/${o.id}'),
child: const Text('修改', child: const Text('修改',
@@ -413,7 +415,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
style: TextStyle(fontSize: 12, color: AppTheme.primary)), style: TextStyle(fontSize: 12, color: AppTheme.primary)),
), ),
], ],
if (o.status == 'pending') ...[ if (!readonly && o.status == 'pending') ...[
TextButton( TextButton(
key: Key('btn_approve_${o.id}'), key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o), onPressed: () => _confirmApprove(context, o),
@@ -20,6 +20,7 @@ import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart'; import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart'; import '../../providers/product_provider.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../core/auth/auth_state.dart';
class StockOutListScreen extends ConsumerStatefulWidget { class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key}); const StockOutListScreen({super.key});
@@ -286,7 +287,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ref.read(stockOutListProvider.notifier).setPageSize(s), ref.read(stockOutListProvider.notifier).setPageSize(s),
toolbar: Row( toolbar: Row(
children: [ children: [
if (showNewButton) if (showNewButton && !ref.watch(isReadonlyProvider))
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => context.go('/stock-out/new'), onPressed: () => context.go('/stock-out/new'),
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
@@ -350,6 +351,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
/// 操作按钮列表,表格与移动端卡片共用。 /// 操作按钮列表,表格与移动端卡片共用。
List<Widget> _orderActions(BuildContext context, StockOutOrder o) { List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
final readonly = ref.watch(isReadonlyProvider);
return [ return [
TextButton( TextButton(
onPressed: () => _showDetail(context, o.id), onPressed: () => _showDetail(context, o.id),
@@ -366,13 +368,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
child: const Text('打印', child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)), style: TextStyle(fontSize: 12, color: AppTheme.primary)),
), ),
if (o.status == 'approved') if (!readonly && o.status == 'approved')
TextButton( TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_out'), onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
child: const Text('结清', child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)), style: TextStyle(fontSize: 12, color: AppTheme.accent)),
), ),
if (o.status == 'draft') ...[ if (!readonly && o.status == 'draft') ...[
TextButton( TextButton(
onPressed: () => context.go('/stock-out/edit/${o.id}'), onPressed: () => context.go('/stock-out/edit/${o.id}'),
child: const Text('修改', child: const Text('修改',
@@ -389,7 +391,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
style: TextStyle(fontSize: 12, color: AppTheme.primary)), style: TextStyle(fontSize: 12, color: AppTheme.primary)),
), ),
], ],
if (o.status == 'pending') ...[ if (!readonly && o.status == 'pending') ...[
TextButton( TextButton(
key: Key('btn_approve_${o.id}'), key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o), onPressed: () => _confirmApprove(context, o),
+39
View File
@@ -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;
}
}