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