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>(
(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 '../../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
@@ -292,7 +292,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
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<InventoryListScreen> {
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<InventoryListScreen> {
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: '库存查询',
@@ -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<PartnersScreen> {
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<PartnersScreen> {
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<PartnersScreen> {
)),
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)),
),
],
)),
],
))
@@ -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<ProductDetailScreen> {
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<ProductDetailScreen> {
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),
),
),
),
),
],
),
@@ -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<ProductsScreen> {
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<ProductsScreen> {
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: [
@@ -218,11 +218,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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<SettingsScreen> {
)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
children: ref.watch(isReadonlyProvider)
? const []
: [
TextButton(
onPressed: () =>
_showEditUserDialog(context, u),
@@ -454,16 +457,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
// ── 数据导入 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);
+24
View File
@@ -210,6 +210,30 @@ class _AppShellState extends ConsumerState<AppShell> {
),
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) ...[
// 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤
@@ -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<StockInListScreen> {
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<StockInListScreen> {
/// 操作按钮列表,表格与移动端卡片共用。
List<Widget> _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<StockInListScreen> {
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<StockInListScreen> {
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),
@@ -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<StockOutListScreen> {
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<StockOutListScreen> {
/// 操作按钮列表,表格与移动端卡片共用。
List<Widget> _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<StockOutListScreen> {
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<StockOutListScreen> {
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),
+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;
}
}