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:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user