From 381588826c58bd9204d356c42f7265b6fad62bc8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 19:09:47 +0800 Subject: [PATCH] chore: release client-v1.0.69 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- CHANGELOG-client.md | 8 ++++ client/lib/core/auth/auth_state.dart | 7 ++++ client/lib/providers/stock_in_provider.dart | 5 +++ client/lib/providers/stock_out_provider.dart | 5 +++ .../lib/repositories/stock_in_repository.dart | 11 +++++ .../repositories/stock_out_repository.dart | 11 +++++ .../stock_in/stock_in_form_screen.dart | 23 ++++++----- .../stock_in/stock_in_list_screen.dart | 40 ++++++++++++++++++ .../stock_out/stock_out_list_screen.dart | 41 +++++++++++++++++++ client/lib/widgets/order_row_actions.dart | 7 ++++ 10 files changed, 147 insertions(+), 11 deletions(-) diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index 84c86e1..2c36d0c 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.69] - 2026-06-21 + +### 新功能 +- 入库/出库审核列表:管理员可「撤回」审核中的单据,单据回到草稿,修改后重新提交审核 + +### 改进 +- 新建入库单:批次号移到「生产日期」右侧、与各列对齐填写;生产日期输入框收窄,不再占用过宽 + ## [1.0.68] - 2026-06-21 ### 改进 diff --git a/client/lib/core/auth/auth_state.dart b/client/lib/core/auth/auth_state.dart index 7d23bd0..2ac7490 100644 --- a/client/lib/core/auth/auth_state.dart +++ b/client/lib/core/auth/auth_state.dart @@ -194,3 +194,10 @@ final isReadonlyProvider = Provider((ref) { final role = ref.watch(authStateProvider.select((s) => s.user?.role)); return role == 'readonly'; }); + +/// 当前登录用户是否为管理员(role == 'admin')。 +/// 用于「撤回审核中单据」等仅管理员可用的操作;后端 middleware.AdminOnly() 兜底。 +final isAdminProvider = Provider((ref) { + final role = ref.watch(authStateProvider.select((s) => s.user?.role)); + return role == 'admin'; +}); diff --git a/client/lib/providers/stock_in_provider.dart b/client/lib/providers/stock_in_provider.dart index fb65533..c41ff28 100644 --- a/client/lib/providers/stock_in_provider.dart +++ b/client/lib/providers/stock_in_provider.dart @@ -123,4 +123,9 @@ class StockInListNotifier extends AsyncNotifier> { await ref.read(stockInRepositoryProvider).reject(id); reload(); } + + Future withdrawOrder(int id) async { + await ref.read(stockInRepositoryProvider).withdraw(id); + reload(); + } } diff --git a/client/lib/providers/stock_out_provider.dart b/client/lib/providers/stock_out_provider.dart index 3962b56..defc28c 100644 --- a/client/lib/providers/stock_out_provider.dart +++ b/client/lib/providers/stock_out_provider.dart @@ -123,4 +123,9 @@ class StockOutListNotifier extends AsyncNotifier> { await ref.read(stockOutRepositoryProvider).reject(id); reload(); } + + Future withdrawOrder(int id) async { + await ref.read(stockOutRepositoryProvider).withdraw(id); + reload(); + } } diff --git a/client/lib/repositories/stock_in_repository.dart b/client/lib/repositories/stock_in_repository.dart index 5fc5d18..f0a6848 100644 --- a/client/lib/repositories/stock_in_repository.dart +++ b/client/lib/repositories/stock_in_repository.dart @@ -119,4 +119,15 @@ class StockInRepository { ); } } + + Future withdraw(int id) async { + try { + await _client.put('/stock-in/orders/$id/withdraw'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '撤回失败', + statusCode: e.response?.statusCode, + ); + } + } } diff --git a/client/lib/repositories/stock_out_repository.dart b/client/lib/repositories/stock_out_repository.dart index c5b1a56..f6c3c7b 100644 --- a/client/lib/repositories/stock_out_repository.dart +++ b/client/lib/repositories/stock_out_repository.dart @@ -119,4 +119,15 @@ class StockOutRepository { ); } } + + Future withdraw(int id) async { + try { + await _client.put('/stock-out/orders/$id/withdraw'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '撤回失败', + statusCode: e.response?.statusCode, + ); + } + } } diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index 700b507..98b8413 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -873,7 +873,6 @@ class _StockInFormScreenState extends ConsumerState { controller: item.batchNoCtrl, decoration: const InputDecoration( hintText: '选填', - labelText: '批次号', isDense: true, ), style: const TextStyle(fontSize: 13), @@ -993,7 +992,8 @@ class _StockInFormScreenState extends ConsumerState { Expanded(flex: 18, child: th('名称')), Expanded(flex: 12, child: th('系列')), Expanded(flex: 12, child: th('规格')), - Expanded(flex: 24, child: th('生产日期')), + Expanded(flex: 14, child: th('生产日期')), + Expanded(flex: 12, child: th('批次号')), Expanded(flex: 10, child: th('数量')), Expanded(flex: 10, child: th('单价')), Expanded(flex: 10, child: th('金额')), @@ -1017,8 +1017,7 @@ class _StockInFormScreenState extends ConsumerState { style: TextStyle(fontSize: 13, color: color, fontWeight: weight)), ); - final hasOptional = item.batchNoCtrl.text.isNotEmpty || - item.selectedOriginId != null || + final hasOptional = item.selectedOriginId != null || item.selectedShelfLifeId != null || item.selectedStorageId != null || item.selectedDescriptionDocId != null; @@ -1050,10 +1049,15 @@ class _StockInFormScreenState extends ConsumerState { padding: const EdgeInsets.all(4), child: _specField(item))), Expanded( - flex: 24, + flex: 14, child: Padding( padding: const EdgeInsets.all(4), child: _dateField(item))), + Expanded( + flex: 12, + child: Padding( + padding: const EdgeInsets.all(4), + child: _batchField(item))), Expanded( flex: 10, child: Padding( @@ -1071,7 +1075,7 @@ class _StockInFormScreenState extends ConsumerState { SizedBox( width: 32, child: Tooltip( - message: item.expanded ? '收起选填' : '展开选填(批次/产地/保质期等)', + message: item.expanded ? '收起选填' : '展开选填(产地/保质期等)', child: IconButton( icon: Icon( item.expanded ? Icons.expand_less : Icons.expand_more, @@ -1131,8 +1135,6 @@ class _StockInFormScreenState extends ConsumerState { spacing: 16, runSpacing: 12, children: [ - _OptionalField( - label: '批次号', width: 180, child: _batchField(item)), _OptionalField( label: '产地', width: 180, child: _originField(item)), _OptionalField( @@ -1170,6 +1172,7 @@ class _StockInFormScreenState extends ConsumerState { MobileCardField('系列', null, valueWidget: _seriesField(item)), MobileCardField('规格', null, valueWidget: _specField(item)), MobileCardField('生产日期', null, valueWidget: _dateField(item)), + MobileCardField('批次号', null, valueWidget: _batchField(item)), MobileCardField('数量', null, valueWidget: _qtyField(item)), MobileCardField('单价', null, valueWidget: _priceField(item)), MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'), @@ -1178,15 +1181,13 @@ class _StockInFormScreenState extends ConsumerState { onPressed: () => setState(() => item.expanded = !item.expanded), icon: Icon(item.expanded ? Icons.expand_less : Icons.expand_more, size: 16), - label: Text(item.expanded ? '收起选填项' : '展开选填项(批次/产地/保质期…)'), + label: Text(item.expanded ? '收起选填项' : '展开选填项(产地/保质期…)'), style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: const Size(0, 32), foregroundColor: AppTheme.textSecondary, ), )), - if (item.expanded) - MobileCardField('批次号', null, valueWidget: _batchField(item)), if (item.expanded) MobileCardField('产地', null, valueWidget: _originField(item)), if (item.expanded) diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 3b910fa..a7439c3 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -25,6 +25,7 @@ import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/shop_provider.dart' show shopInfoProvider; import '../../widgets/write_guard.dart'; import '../../widgets/order_row_actions.dart'; +import '../../core/auth/auth_state.dart' show isAdminProvider; class StockInListScreen extends ConsumerStatefulWidget { const StockInListScreen({super.key}); @@ -478,6 +479,7 @@ class _StockInListScreenState extends ConsumerState { List _orderActions(BuildContext context, StockInOrder o) { return buildOrderRowActions( readonly: WriteGuard.isReadonly(ref), + isAdmin: ref.watch(isAdminProvider), status: o.status, orderId: o.id, onDetail: () => _showDetail(context, o.id), @@ -525,6 +527,7 @@ class _StockInListScreenState extends ConsumerState { onSubmit: () => _confirmSubmit(context, o), onApprove: () => _confirmApprove(context, o), onReject: () => _confirmReject(context, o), + onWithdraw: () => _confirmWithdraw(context, o), ); } @@ -754,6 +757,43 @@ class _StockInListScreenState extends ConsumerState { } } } + + Future _confirmWithdraw(BuildContext context, StockInOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('撤回确认'), + content: Text('确认撤回入库单「${o.orderNo}」?撤回后单据回到草稿,可修改后重新提交审核。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accent, + foregroundColor: Colors.white), + child: const Text('撤回'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref.read(stockInListProvider.notifier).withdrawOrder(o.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已撤回为草稿'), backgroundColor: AppTheme.accent)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('撤回失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } + } } // Detail dialog — fetches full order with items diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 6887586..7abeec5 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -23,6 +23,7 @@ import '../../providers/product_provider.dart'; import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../widgets/write_guard.dart'; import '../../widgets/order_row_actions.dart'; +import '../../core/auth/auth_state.dart' show isAdminProvider; class StockOutListScreen extends ConsumerStatefulWidget { const StockOutListScreen({super.key}); @@ -484,6 +485,7 @@ class _StockOutListScreenState extends ConsumerState { List _orderActions(BuildContext context, StockOutOrder o) { return buildOrderRowActions( readonly: WriteGuard.isReadonly(ref), + isAdmin: ref.watch(isAdminProvider), status: o.status, orderId: o.id, onDetail: () => _showDetail(context, o.id), @@ -499,6 +501,7 @@ class _StockOutListScreenState extends ConsumerState { onSubmit: () => _confirmSubmit(context, o), onApprove: () => _confirmApprove(context, o), onReject: () => _confirmReject(context, o), + onWithdraw: () => _confirmWithdraw(context, o), ); } @@ -732,6 +735,44 @@ class _StockOutListScreenState extends ConsumerState { } } } + + Future _confirmWithdraw( + BuildContext context, StockOutOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('撤回确认'), + content: Text('确认撤回出库单「${o.orderNo}」?撤回后单据回到草稿,可修改后重新提交审核。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accent, + foregroundColor: Colors.white), + child: const Text('撤回'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref.read(stockOutListProvider.notifier).withdrawOrder(o.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已撤回为草稿'), backgroundColor: AppTheme.accent)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('撤回失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } + } } // Detail dialog — fetches full order with items diff --git a/client/lib/widgets/order_row_actions.dart b/client/lib/widgets/order_row_actions.dart index cb57197..a7b0ebc 100644 --- a/client/lib/widgets/order_row_actions.dart +++ b/client/lib/widgets/order_row_actions.dart @@ -23,6 +23,8 @@ List buildOrderRowActions({ required VoidCallback onSubmit, required VoidCallback onApprove, required VoidCallback onReject, + required VoidCallback onWithdraw, + bool isAdmin = false, List afterPrint = const [], }) { TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) => @@ -49,6 +51,11 @@ List buildOrderRowActions({ WriteGuard( child: btn('拒绝', AppTheme.danger, onReject, key: Key('btn_reject_$orderId'))), + // 撤回(审核中→草稿)仅管理员可见 + if (isAdmin) + WriteGuard( + child: btn('撤回', AppTheme.accent, onWithdraw, + key: Key('btn_withdraw_$orderId'))), ], ]; }