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
@@ -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: [