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