From a76724b385d30ef92680e76997041223c73d96ad Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 20 Jun 2026 07:05:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=BD=95=E5=8D=95=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E4=BC=98=E5=8C=96=20+=20=E5=8F=AF=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E6=94=AF=E6=8C=81=E6=96=B0=E5=BB=BA=20+=20?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E9=80=89=E6=8B=A9=E5=99=A8=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 DatePickerField 日期选择器与 date_util;可搜索下拉 SearchableOptionField 支持内联新建选项;入库/出库录单表单、商品页、财务页、app_shell 导航相应调整。 Co-Authored-By: Claude Opus 4.8 --- client/lib/core/router/app_router.dart | 171 +-- client/lib/core/utils/date_util.dart | 22 + client/lib/models/shop.dart | 21 + .../lib/screens/finance/finance_screen.dart | 201 +-- .../lib/screens/products/products_screen.dart | 1087 ++++++++++++----- client/lib/screens/shell/app_shell.dart | 884 +++++++------- .../stock_in/stock_in_form_screen.dart | 925 ++++++++------ .../stock_out/stock_out_form_screen.dart | 768 +++++++----- client/lib/widgets/date_picker_field.dart | 179 +++ .../lib/widgets/searchable_option_field.dart | 119 +- client/test/date_picker_field_test.dart | 56 + .../test/searchable_option_create_test.dart | 59 + 12 files changed, 2868 insertions(+), 1624 deletions(-) create mode 100644 client/lib/core/utils/date_util.dart create mode 100644 client/lib/widgets/date_picker_field.dart create mode 100644 client/test/date_picker_field_test.dart create mode 100644 client/test/searchable_option_create_test.dart diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index c2b8759..1906216 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -20,8 +20,7 @@ import '../../screens/about/about_screen.dart'; import '../../screens/devices/device_management_screen.dart'; import '../auth/auth_state.dart'; -Page _noTransition(Widget child) => - NoTransitionPage(child: child); +Page _noTransition(Widget child) => NoTransitionPage(child: child); /// ChangeNotifier that bridges Riverpod auth state → GoRouter refreshListenable. class _RouterNotifier extends ChangeNotifier { @@ -94,74 +93,106 @@ final appRouterProvider = Provider((ref) { path: '/login', builder: (context, state) => const LoginScreen(), ), - ShellRoute( - builder: (context, state, child) => AppShell(child: child), - routes: [ - GoRoute( - path: '/stock-in', - pageBuilder: (_, __) => _noTransition(const StockInListScreen())), - GoRoute( - path: '/stock-in/new', - pageBuilder: (_, __) => _noTransition(const StockInFormScreen())), - GoRoute( - path: '/stock-in/edit/:id', - pageBuilder: (_, state) => _noTransition( - StockInFormScreen( - editOrderId: int.parse(state.pathParameters['id']!)))), - GoRoute( - path: '/stock-out', - pageBuilder: (_, __) => _noTransition(const StockOutListScreen())), - GoRoute( - path: '/stock-out/new', - pageBuilder: (_, __) => _noTransition(const StockOutFormScreen())), - GoRoute( - path: '/stock-out/edit/:id', - pageBuilder: (_, state) => _noTransition( - StockOutFormScreen( - editOrderId: int.parse(state.pathParameters['id']!)))), - GoRoute( - path: '/inventory', - pageBuilder: (_, __) => - _noTransition(const InventoryListScreen())), - GoRoute( - path: '/inventory/check', - pageBuilder: (_, __) => - _noTransition(const InventoryCheckScreen())), - GoRoute( - path: '/partners', - pageBuilder: (_, __) => _noTransition(const PartnersScreen())), - GoRoute( - path: '/finance', - pageBuilder: (_, __) => _noTransition(const FinanceScreen())), - GoRoute( - path: '/products', - pageBuilder: (_, __) => _noTransition(const ProductsScreen())), - GoRoute( - path: '/products/:id', - pageBuilder: (_, state) => _noTransition(ProductDetailScreen( - productId: int.parse(state.pathParameters['id']!)))), - GoRoute( - path: '/settings', - pageBuilder: (_, state) { - const tabIndex = { - 'shop': 0, - 'users': 1, - 'number': 2, - 'system': 3, - 'license': 4, - 'import': 5, - }; - final tab = - tabIndex[state.uri.queryParameters['tab']] ?? 0; - return _noTransition(SettingsScreen(initialTab: tab)); - }), - GoRoute( - path: '/devices', - pageBuilder: (_, __) => - _noTransition(const DeviceManagementScreen())), - GoRoute( - path: '/about', - pageBuilder: (_, __) => _noTransition(const AboutScreen())), + // 各栏目拆为独立分支:StatefulShellRoute.indexedStack 让每个分支的 Navigator + // 及其页面 State 常驻,跨栏目切换不再销毁上一页(半填表单/内部 tab/滚动位置保活)。 + // 分支顺序必须与 AppShell._navItems 一致(navigationShell.currentIndex 据此高亮)。 + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => + AppShell(navigationShell: navigationShell), + branches: [ + // 0 入库管理 + StatefulShellBranch(routes: [ + GoRoute( + path: '/stock-in', + pageBuilder: (_, __) => + _noTransition(const StockInListScreen())), + GoRoute( + path: '/stock-in/new', + pageBuilder: (_, __) => + _noTransition(const StockInFormScreen())), + GoRoute( + path: '/stock-in/edit/:id', + pageBuilder: (_, state) => _noTransition(StockInFormScreen( + editOrderId: int.parse(state.pathParameters['id']!)))), + ]), + // 1 出库管理 + StatefulShellBranch(routes: [ + GoRoute( + path: '/stock-out', + pageBuilder: (_, __) => + _noTransition(const StockOutListScreen())), + GoRoute( + path: '/stock-out/new', + pageBuilder: (_, __) => + _noTransition(const StockOutFormScreen())), + GoRoute( + path: '/stock-out/edit/:id', + pageBuilder: (_, state) => _noTransition(StockOutFormScreen( + editOrderId: int.parse(state.pathParameters['id']!)))), + ]), + // 2 库存管理 + StatefulShellBranch(routes: [ + GoRoute( + path: '/inventory', + pageBuilder: (_, __) => + _noTransition(const InventoryListScreen())), + GoRoute( + path: '/inventory/check', + pageBuilder: (_, __) => + _noTransition(const InventoryCheckScreen())), + ]), + // 3 财务管理 + StatefulShellBranch(routes: [ + GoRoute( + path: '/finance', + pageBuilder: (_, __) => _noTransition(const FinanceScreen())), + ]), + // 4 往来单位 + StatefulShellBranch(routes: [ + GoRoute( + path: '/partners', + pageBuilder: (_, __) => _noTransition(const PartnersScreen())), + ]), + // 5 基础数据 + StatefulShellBranch(routes: [ + GoRoute( + path: '/products', + pageBuilder: (_, __) => _noTransition(const ProductsScreen())), + GoRoute( + path: '/products/:id', + pageBuilder: (_, state) => _noTransition(ProductDetailScreen( + productId: int.parse(state.pathParameters['id']!)))), + ]), + // 6 设备管理 + StatefulShellBranch(routes: [ + GoRoute( + path: '/devices', + pageBuilder: (_, __) => + _noTransition(const DeviceManagementScreen())), + ]), + // 7 系统设置 + StatefulShellBranch(routes: [ + GoRoute( + path: '/settings', + pageBuilder: (_, state) { + const tabIndex = { + 'shop': 0, + 'users': 1, + 'number': 2, + 'system': 3, + 'license': 4, + 'import': 5, + }; + final tab = tabIndex[state.uri.queryParameters['tab']] ?? 0; + return _noTransition(SettingsScreen(initialTab: tab)); + }), + ]), + // 8 关于我们 + StatefulShellBranch(routes: [ + GoRoute( + path: '/about', + pageBuilder: (_, __) => _noTransition(const AboutScreen())), + ]), ], ), ], diff --git a/client/lib/core/utils/date_util.dart b/client/lib/core/utils/date_util.dart new file mode 100644 index 0000000..de6b6d2 --- /dev/null +++ b/client/lib/core/utils/date_util.dart @@ -0,0 +1,22 @@ +/// 日期工具:统一 `yyyy-MM-dd` 的格式化与解析,收敛各处重复的 padLeft 逻辑。 +library; + +/// 格式化为 `yyyy-MM-dd`(提交后端的统一格式)。 +String formatYmd(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + +/// 解析 `yyyy-MM-dd`(或更长的带时间字符串,取前 10 位)。无效返回 null。 +DateTime? parseYmd(String? s) { + if (s == null || s.isEmpty) return null; + final t = s.length >= 10 ? s.substring(0, 10) : s; + return DateTime.tryParse(t); +} + +/// 组合年/月/日为 `yyyy-MM-dd`,自动把超出当月的「日」clamp 回当月最大值 +/// (如 2 月选 31 → 28/29)。任一为空返回 null。 +String? composeYmd(int? year, int? month, int? day) { + if (year == null || month == null || day == null) return null; + final maxDay = DateTime(year, month + 1, 0).day; // 下月第 0 天 = 当月最后一天 + final d = day > maxDay ? maxDay : day; + return formatYmd(DateTime(year, month, d)); +} diff --git a/client/lib/models/shop.dart b/client/lib/models/shop.dart index 3725ef6..194f32f 100644 --- a/client/lib/models/shop.dart +++ b/client/lib/models/shop.dart @@ -7,6 +7,8 @@ class ShopInfo { final String managerName; final String logoUrl; final String wechatId; + // 店级动态配置(后端 shops.custom_fields JSON),存录入默认值等轻量配置 + final Map customFields; const ShopInfo({ required this.id, @@ -17,8 +19,24 @@ class ShopInfo { required this.managerName, this.logoUrl = '', this.wechatId = '', + this.customFields = const {}, }); + /// 入库录入默认商品名称选项 id(未配置返回 null)。 + int? get defaultNameId => _asInt(customFields['default_name_id']); + + /// 入库录入默认系列选项 id(未配置返回 null)。 + int? get defaultSeriesId => _asInt(customFields['default_series_id']); + + /// 入库录入默认规格选项 id(未配置返回 null)。 + int? get defaultSpecId => _asInt(customFields['default_spec_id']); + + static int? _asInt(dynamic v) { + if (v == null) return null; + if (v is num) return v.toInt(); + return int.tryParse(v.toString()); + } + factory ShopInfo.fromJson(Map json) => ShopInfo( id: (json['id'] as num).toInt(), code: json['code'] as String? ?? '', @@ -28,5 +46,8 @@ class ShopInfo { managerName: json['manager_name'] as String? ?? '', logoUrl: json['logo_url'] as String? ?? '', wechatId: json['wechat_id'] as String? ?? '', + customFields: + (json['custom_fields'] as Map?)?.cast() ?? + const {}, ); } diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index d1667d1..e08742a 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -6,13 +6,16 @@ import '../../models/finance.dart'; import '../../providers/finance_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; -import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; +import '../../widgets/multi_select_dropdown.dart' + show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../core/storage/column_prefs.dart'; import '../../widgets/page_scaffold.dart'; import '../../providers/connectivity_provider.dart'; import '../../core/utils/export_util.dart'; +import '../../core/utils/date_util.dart'; import '../../repositories/finance_repository.dart'; import '../../widgets/write_guard.dart'; +import '../../widgets/date_picker_field.dart'; class FinanceScreen extends ConsumerWidget { const FinanceScreen({super.key}); @@ -140,11 +143,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { /// 财务记录:窄屏卡片 Widget _financeCard(FinanceRecord r) { - final canClose = (r.type == 'payable' || r.type == 'receivable') && - r.status == 'open'; + final canClose = + (r.type == 'payable' || r.type == 'receivable') && r.status == 'open'; final showStatus = r.type == 'payable' || r.type == 'receivable'; return MobileListCard( - title: Text(r.partnerName?.isNotEmpty == true ? r.partnerName! : r.typeLabel), + title: Text( + r.partnerName?.isNotEmpty == true ? r.partnerName! : r.typeLabel), subtitle: Text(r.recordDate?.substring(0, 10) ?? '-'), trailing: _TypeBadge(r.typeLabel), fields: [ @@ -154,9 +158,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { '¥${r.balance.toStringAsFixed(2)}', style: TextStyle( fontSize: 13, - color: r.balance > 0 - ? AppTheme.danger - : AppTheme.textSecondary, + color: r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary, fontWeight: FontWeight.w600, ), )), @@ -204,9 +206,11 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), + const Icon(Icons.cloud_off, + size: 40, color: AppTheme.textSecondary), const SizedBox(height: 12), - const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)), + const Text('暂无数据,网络不可用', + style: TextStyle(color: AppTheme.textSecondary)), const SizedBox(height: 12), ElevatedButton(onPressed: _refetch, child: const Text('重试')), ], @@ -223,13 +227,18 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { // Summary: only for payable/receivable tabs final totalAmount = records.fold(0.0, (s, r) => s + r.amount); final openAmount = records - .where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'open') + .where((r) => + (r.type == 'payable' || r.type == 'receivable') && + r.status == 'open') .fold(0.0, (s, r) => s + r.amount); final closedAmount = records - .where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'closed') + .where((r) => + (r.type == 'payable' || r.type == 'receivable') && + r.status == 'closed') .fold(0.0, (s, r) => s + r.amount); - final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList()..sort(); + final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList() + ..sort(); final partnerOptions = _allRecords .map((r) => r.partnerName ?? '') .where((s) => s.isNotEmpty) @@ -245,8 +254,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { .map((c) => c.key) .toSet(); // 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。 - final visibleCols = - _colDefs.where((c) => !hidden.contains(c.key)).toList(); + final visibleCols = _colDefs.where((c) => !hidden.contains(c.key)).toList(); final columns = visibleCols.map((c) { final label = switch (c.key) { @@ -265,8 +273,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { _ => Text(c.label), }; return DataColumn( - label: label, - numeric: c.key == 'amount' || c.key == 'balance'); + label: label, numeric: c.key == 'amount' || c.key == 'balance'); }).toList(); DataCell buildFinanceCell(String key, FinanceRecord r) { @@ -314,7 +321,8 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { width: 160, child: Text(r.remark ?? '-', overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), )); case 'actions': if ((r.type == 'payable' || r.type == 'receivable') && @@ -348,7 +356,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { ] : records .map((r) => DataRow( - cells: visibleCols.map((c) => buildFinanceCell(c.key, r)).toList(), + cells: visibleCols + .map((c) => buildFinanceCell(c.key, r)) + .toList(), )) .toList(); @@ -441,19 +451,30 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { : '应收账款'; exportExcel( filename: tabName, - headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '状态', '备注'], - rows: records.map((r) => [ - r.recordDate?.substring(0, 10) ?? '', - r.typeLabel, - r.partnerName ?? '', - r.refType != null && r.refId != null - ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' - : '', - r.amount, - r.balance, - r.status == 'open' ? '未结清' : '已结清', - r.remark ?? '', - ]).toList(), + headers: [ + '日期', + '类型', + '往来单位', + '关联单据', + '金额', + '余额', + '状态', + '备注' + ], + rows: records + .map((r) => [ + r.recordDate?.substring(0, 10) ?? '', + r.typeLabel, + r.partnerName ?? '', + r.refType != null && r.refId != null + ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' + : '', + r.amount, + r.balance, + r.status == 'open' ? '未结清' : '已结清', + r.remark ?? '', + ]) + .toList(), ); }, icon: const Icon(Icons.download, size: 16), @@ -528,7 +549,8 @@ class _AddPaymentDialogState extends State<_AddPaymentDialog> { final amount = double.tryParse(_amountCtrl.text.trim()); if (amount == null || amount <= 0) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请输入有效金额'), backgroundColor: AppTheme.danger), + const SnackBar( + content: Text('请输入有效金额'), backgroundColor: AppTheme.danger), ); return; } @@ -537,15 +559,17 @@ class _AddPaymentDialogState extends State<_AddPaymentDialog> { final body = { 'type': _type, 'amount': amount, - 'record_date': '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}', - if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(), + 'record_date': formatYmd(_date), + if (_remarkCtrl.text.trim().isNotEmpty) + 'remark': _remarkCtrl.text.trim(), }; await widget.repo.create(body); if (mounted) { Navigator.of(context).pop(); widget.onSaved(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('添加成功'), backgroundColor: AppTheme.success), + const SnackBar( + content: Text('添加成功'), backgroundColor: AppTheme.success), ); } } catch (e) { @@ -570,27 +594,19 @@ class _AddPaymentDialogState extends State<_AddPaymentDialog> { children: [ TextField( controller: _amountCtrl, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration(labelText: '金额', prefixText: '¥ '), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: + const InputDecoration(labelText: '金额', prefixText: '¥ '), ), const SizedBox(height: 12), - InkWell( - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: _date, - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 30)), - ); - if (picked != null) setState(() => _date = picked); + DatePickerField( + label: '日期', + value: formatYmd(_date), + onChanged: (v) { + final d = parseYmd(v); + if (d != null) setState(() => _date = d); }, - child: InputDecorator( - decoration: const InputDecoration(labelText: '日期'), - child: Text( - '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 14), - ), - ), ), const SizedBox(height: 12), TextField( @@ -609,8 +625,10 @@ class _AddPaymentDialogState extends State<_AddPaymentDialog> { onPressed: _saving ? null : _save, child: _saving ? const SizedBox( - width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) : const Text('保存'), ), ], @@ -678,7 +696,8 @@ class _TypeBadge extends StatelessWidget { decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)), child: Text(label, - style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)), + style: + TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)), ); } } @@ -701,43 +720,41 @@ class _SummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { final card = Container( - height: 72, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: AppTheme.border, width: 0.5), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: color, size: 22), + height: 72, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppTheme.border, width: 0.5), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(title, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - const SizedBox(height: 4), - Text(value, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: color)), - ], - ), - ], - ), - ); + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(title, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const SizedBox(height: 4), + Text(value, + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.w700, color: color)), + ], + ), + ], + ), + ); return width != null ? SizedBox(width: width, child: card) : Expanded(child: card); diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index 9a8755e..78dba76 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -6,6 +6,7 @@ import '../../core/theme/app_theme.dart'; import '../../models/warehouse.dart'; import '../../providers/product_option_provider.dart'; import '../../providers/warehouse_provider.dart'; +import '../../providers/shop_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/page_scaffold.dart'; @@ -20,6 +21,9 @@ class ProductsScreen extends ConsumerStatefulWidget { } class _ProductsScreenState extends ConsumerState { + // 「设为默认」的乐观覆盖:点一下立刻变色,不等后端回包(key→选项 id)。 + final Map _optDefaults = {}; + final _nameSearchCtrl = TextEditingController(); final _seriesSearchCtrl = TextEditingController(); final _specSearchCtrl = TextEditingController(); @@ -28,13 +32,20 @@ class _ProductsScreenState extends ConsumerState { final _storageSearchCtrl = TextEditingController(); final _descDocSearchCtrl = TextEditingController(); - int _namePage = 1; int _namePageSize = 20; - int _seriesPage = 1; int _seriesPageSize = 20; - int _specPage = 1; int _specPageSize = 20; - int _originPage = 1; int _originPageSize = 20; - int _shelfLifePage = 1; int _shelfLifePageSize = 20; - int _storagePage = 1; int _storagePageSize = 20; - int _descDocPage = 1; int _descDocPageSize = 20; + int _namePage = 1; + int _namePageSize = 20; + int _seriesPage = 1; + int _seriesPageSize = 20; + int _specPage = 1; + int _specPageSize = 20; + int _originPage = 1; + int _originPageSize = 20; + int _shelfLifePage = 1; + int _shelfLifePageSize = 20; + int _storagePage = 1; + int _storagePageSize = 20; + int _descDocPage = 1; + int _descDocPageSize = 20; @override void dispose() { @@ -81,21 +92,32 @@ class _ProductsScreenState extends ConsumerState { final async = ref.watch(productNameListProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => _buildError(() => ref.read(productNameListProvider.notifier).reload()), + error: (e, _) => _buildError( + () => ref.read(productNameListProvider.notifier).reload()), data: (items) { + final defaultId = _effectiveDefault('default_name_id', + ref.watch(shopInfoProvider).valueOrNull?.defaultNameId); final keyword = _nameSearchCtrl.text.toLowerCase(); final filtered = keyword.isEmpty ? items - : items.where((it) => - it.name.toLowerCase().contains(keyword) || - (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); - final paged = filtered.skip((_namePage - 1) * _namePageSize).take(_namePageSize).toList(); + : items + .where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)) + .toList(); + final paged = filtered + .skip((_namePage - 1) * _namePageSize) + .take(_namePageSize) + .toList(); return DataTableCard( totalCount: filtered.length, page: _namePage, pageSize: _namePageSize, onPageChanged: (p) => setState(() => _namePage = p), - onPageSizeChanged: (s) => setState(() { _namePageSize = s; _namePage = 1; }), + onPageSizeChanged: (s) => setState(() { + _namePageSize = s; + _namePage = 1; + }), toolbar: _buildToolbar( searchCtrl: _nameSearchCtrl, hint: '搜索名称/编号', @@ -103,12 +125,15 @@ class _ProductsScreenState extends ConsumerState { onAdd: () => _showOptionDialog( title: '新建商品名称', hasQuantity: false, - onSave: (data) => ref.read(productNameListProvider.notifier).create(data), + onSave: (data) => + ref.read(productNameListProvider.notifier).create(data), ), onExport: () => exportExcel( filename: '商品名称', headers: ['编号', '名称', '备注'], - rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(), + rows: items + .map((it) => [it.code ?? '', it.name, it.remark ?? '']) + .toList(), ), ), mobileCards: paged @@ -116,15 +141,26 @@ class _ProductsScreenState extends ConsumerState { code: it.code, name: it.name, remark: it.remark, + isDefault: it.id == defaultId, + onSetDefault: () => + _setDefaultOption('default_name_id', it.id), onEdit: () => _showOptionDialog( title: '编辑商品名称', hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productNameListProvider.notifier).updateItem(it.id, data), + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productNameListProvider.notifier) + .updateItem(it.id, data), ), onDelete: () => _confirmDelete( '删除名称「${it.name}」?', - () => ref.read(productNameListProvider.notifier).delete(it.id), + () => ref + .read(productNameListProvider.notifier) + .delete(it.id), ), )) .toList(), @@ -135,30 +171,51 @@ class _ProductsScreenState extends ConsumerState { DataColumn(label: Text('操作')), ], rows: paged.isEmpty - ? [DataRow(cells: [ - const DataCell(SizedBox()), - const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), - const DataCell(SizedBox()), - const DataCell(SizedBox()), - ])] - : paged.map((it) => DataRow(cells: [ - DataCell(Text(it.code ?? '-', - style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), - DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(it.remark ?? '-')), - DataCell(_actionButtons( - onEdit: () => _showOptionDialog( - title: '编辑商品名称', - hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productNameListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete( - '删除名称「${it.name}」?', - () => ref.read(productNameListProvider.notifier).delete(it.id), - ), - )), - ])).toList(), + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', + style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + ]) + ] + : paged + .map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(Text(it.name, + style: + const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(it.remark ?? '-')), + DataCell(_actionButtons( + isDefault: it.id == defaultId, + onSetDefault: () => + _setDefaultOption('default_name_id', it.id), + onEdit: () => _showOptionDialog( + title: '编辑商品名称', + hasQuantity: false, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productNameListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除名称「${it.name}」?', + () => ref + .read(productNameListProvider.notifier) + .delete(it.id), + ), + )), + ])) + .toList(), ); }, ); @@ -170,21 +227,32 @@ class _ProductsScreenState extends ConsumerState { final async = ref.watch(productSeriesListProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => _buildError(() => ref.read(productSeriesListProvider.notifier).reload()), + error: (e, _) => _buildError( + () => ref.read(productSeriesListProvider.notifier).reload()), data: (items) { + final defaultId = _effectiveDefault('default_series_id', + ref.watch(shopInfoProvider).valueOrNull?.defaultSeriesId); final keyword = _seriesSearchCtrl.text.toLowerCase(); final filtered = keyword.isEmpty ? items - : items.where((it) => - it.name.toLowerCase().contains(keyword) || - (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); - final paged = filtered.skip((_seriesPage - 1) * _seriesPageSize).take(_seriesPageSize).toList(); + : items + .where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)) + .toList(); + final paged = filtered + .skip((_seriesPage - 1) * _seriesPageSize) + .take(_seriesPageSize) + .toList(); return DataTableCard( totalCount: filtered.length, page: _seriesPage, pageSize: _seriesPageSize, onPageChanged: (p) => setState(() => _seriesPage = p), - onPageSizeChanged: (s) => setState(() { _seriesPageSize = s; _seriesPage = 1; }), + onPageSizeChanged: (s) => setState(() { + _seriesPageSize = s; + _seriesPage = 1; + }), toolbar: _buildToolbar( searchCtrl: _seriesSearchCtrl, hint: '搜索系列/编号', @@ -192,12 +260,15 @@ class _ProductsScreenState extends ConsumerState { onAdd: () => _showOptionDialog( title: '新建系列', hasQuantity: false, - onSave: (data) => ref.read(productSeriesListProvider.notifier).create(data), + onSave: (data) => + ref.read(productSeriesListProvider.notifier).create(data), ), onExport: () => exportExcel( filename: '商品系列', headers: ['编号', '系列名称', '备注'], - rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(), + rows: items + .map((it) => [it.code ?? '', it.name, it.remark ?? '']) + .toList(), ), ), mobileCards: paged @@ -205,15 +276,26 @@ class _ProductsScreenState extends ConsumerState { code: it.code, name: it.name, remark: it.remark, + isDefault: it.id == defaultId, + onSetDefault: () => + _setDefaultOption('default_series_id', it.id), onEdit: () => _showOptionDialog( title: '编辑系列', hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productSeriesListProvider.notifier).updateItem(it.id, data), + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productSeriesListProvider.notifier) + .updateItem(it.id, data), ), onDelete: () => _confirmDelete( '删除系列「${it.name}」?', - () => ref.read(productSeriesListProvider.notifier).delete(it.id), + () => ref + .read(productSeriesListProvider.notifier) + .delete(it.id), ), )) .toList(), @@ -224,30 +306,51 @@ class _ProductsScreenState extends ConsumerState { DataColumn(label: Text('操作')), ], rows: paged.isEmpty - ? [DataRow(cells: [ - const DataCell(SizedBox()), - const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), - const DataCell(SizedBox()), - const DataCell(SizedBox()), - ])] - : paged.map((it) => DataRow(cells: [ - DataCell(Text(it.code ?? '-', - style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), - DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(it.remark ?? '-')), - DataCell(_actionButtons( - onEdit: () => _showOptionDialog( - title: '编辑系列', - hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productSeriesListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete( - '删除系列「${it.name}」?', - () => ref.read(productSeriesListProvider.notifier).delete(it.id), - ), - )), - ])).toList(), + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', + style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + ]) + ] + : paged + .map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(Text(it.name, + style: + const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(it.remark ?? '-')), + DataCell(_actionButtons( + isDefault: it.id == defaultId, + onSetDefault: () => + _setDefaultOption('default_series_id', it.id), + onEdit: () => _showOptionDialog( + title: '编辑系列', + hasQuantity: false, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productSeriesListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除系列「${it.name}」?', + () => ref + .read(productSeriesListProvider.notifier) + .delete(it.id), + ), + )), + ])) + .toList(), ); }, ); @@ -259,21 +362,32 @@ class _ProductsScreenState extends ConsumerState { final async = ref.watch(productSpecListProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => _buildError(() => ref.read(productSpecListProvider.notifier).reload()), + error: (e, _) => _buildError( + () => ref.read(productSpecListProvider.notifier).reload()), data: (items) { + final defaultId = _effectiveDefault('default_spec_id', + ref.watch(shopInfoProvider).valueOrNull?.defaultSpecId); final keyword = _specSearchCtrl.text.toLowerCase(); final filtered = keyword.isEmpty ? items - : items.where((it) => - it.name.toLowerCase().contains(keyword) || - (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); - final paged = filtered.skip((_specPage - 1) * _specPageSize).take(_specPageSize).toList(); + : items + .where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)) + .toList(); + final paged = filtered + .skip((_specPage - 1) * _specPageSize) + .take(_specPageSize) + .toList(); return DataTableCard( totalCount: filtered.length, page: _specPage, pageSize: _specPageSize, onPageChanged: (p) => setState(() => _specPage = p), - onPageSizeChanged: (s) => setState(() { _specPageSize = s; _specPage = 1; }), + onPageSizeChanged: (s) => setState(() { + _specPageSize = s; + _specPage = 1; + }), toolbar: _buildToolbar( searchCtrl: _specSearchCtrl, hint: '搜索规格/编号', @@ -281,12 +395,16 @@ class _ProductsScreenState extends ConsumerState { onAdd: () => _showOptionDialog( title: '新建规格', hasQuantity: true, - onSave: (data) => ref.read(productSpecListProvider.notifier).create(data), + onSave: (data) => + ref.read(productSpecListProvider.notifier).create(data), ), onExport: () => exportExcel( filename: '商品规格', headers: ['编号', '规格名称', '单品数量', '备注'], - rows: items.map((it) => [it.code ?? '', it.name, it.quantity, it.remark ?? '']).toList(), + rows: items + .map((it) => + [it.code ?? '', it.name, it.quantity, it.remark ?? '']) + .toList(), ), ), mobileCards: paged @@ -295,15 +413,27 @@ class _ProductsScreenState extends ConsumerState { name: it.name, remark: it.remark, quantity: it.quantity, + isDefault: it.id == defaultId, + onSetDefault: () => + _setDefaultOption('default_spec_id', it.id), onEdit: () => _showOptionDialog( title: '编辑规格', hasQuantity: true, - initial: {'code': it.code ?? '', 'name': it.name, 'quantity': it.quantity, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productSpecListProvider.notifier).updateItem(it.id, data), + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'quantity': it.quantity, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productSpecListProvider.notifier) + .updateItem(it.id, data), ), onDelete: () => _confirmDelete( '删除规格「${it.name}」?', - () => ref.read(productSpecListProvider.notifier).delete(it.id), + () => ref + .read(productSpecListProvider.notifier) + .delete(it.id), ), )) .toList(), @@ -315,32 +445,55 @@ class _ProductsScreenState extends ConsumerState { DataColumn(label: Text('操作')), ], rows: paged.isEmpty - ? [DataRow(cells: [ - const DataCell(SizedBox()), - const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), - const DataCell(SizedBox()), - const DataCell(SizedBox()), - const DataCell(SizedBox()), - ])] - : paged.map((it) => DataRow(cells: [ - DataCell(Text(it.code ?? '-', - style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), - DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(it.quantity > 0 ? '${it.quantity}' : '-')), - DataCell(Text(it.remark ?? '-')), - DataCell(_actionButtons( - onEdit: () => _showOptionDialog( - title: '编辑规格', - hasQuantity: true, - initial: {'code': it.code ?? '', 'name': it.name, 'quantity': it.quantity, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productSpecListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete( - '删除规格「${it.name}」?', - () => ref.read(productSpecListProvider.notifier).delete(it.id), - ), - )), - ])).toList(), + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', + style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + ]) + ] + : paged + .map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(Text(it.name, + style: + const TextStyle(fontWeight: FontWeight.w500))), + DataCell( + Text(it.quantity > 0 ? '${it.quantity}' : '-')), + DataCell(Text(it.remark ?? '-')), + DataCell(_actionButtons( + isDefault: it.id == defaultId, + onSetDefault: () => + _setDefaultOption('default_spec_id', it.id), + onEdit: () => _showOptionDialog( + title: '编辑规格', + hasQuantity: true, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'quantity': it.quantity, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productSpecListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除规格「${it.name}」?', + () => ref + .read(productSpecListProvider.notifier) + .delete(it.id), + ), + )), + ])) + .toList(), ); }, ); @@ -396,7 +549,8 @@ class _ProductsScreenState extends ConsumerState { children: [ const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), const SizedBox(height: 12), - const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)), + const Text('暂无数据,网络不可用', + style: TextStyle(color: AppTheme.textSecondary)), const SizedBox(height: 12), ElevatedButton(onPressed: onRetry, child: const Text('重试')), ], @@ -412,9 +566,22 @@ class _ProductsScreenState extends ConsumerState { int? quantity, required VoidCallback onEdit, required VoidCallback onDelete, + bool isDefault = false, + VoidCallback? onSetDefault, }) { return MobileListCard( - title: Text(name), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isDefault) + const Padding( + padding: EdgeInsets.only(right: 4), + child: + Icon(Icons.star_rounded, size: 18, color: Color(0xFFFFB300)), + ), + Flexible(child: Text(name)), + ], + ), subtitle: (code?.isNotEmpty == true) ? Text(code!) : null, fields: [ if (quantity != null && quantity > 0) @@ -424,6 +591,14 @@ class _ProductsScreenState extends ConsumerState { actions: WriteGuard.isReadonly(ref) ? const [] : [ + if (onSetDefault != null) + WriteGuard( + child: TextButton( + onPressed: isDefault ? null : onSetDefault, + child: Text(isDefault ? '默认' : '设为默认', + style: const TextStyle(fontSize: 13)), + ), + ), WriteGuard( child: TextButton( onPressed: onEdit, @@ -441,35 +616,99 @@ class _ProductsScreenState extends ConsumerState { ); } - Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) { + Widget _actionButtons({ + required VoidCallback onEdit, + required VoidCallback onDelete, + bool isDefault = false, + VoidCallback? onSetDefault, + }) { return WriteGuard( child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (onSetDefault != null) + IconButton( + tooltip: isDefault ? '当前默认' : '设为入库默认', + visualDensity: VisualDensity.compact, + icon: Icon( + isDefault ? Icons.star_rounded : Icons.star_border_rounded, + size: 20, + color: isDefault + ? const Color(0xFFFFB300) // 明亮金色,醒目区分默认项 + : AppTheme.textSecondary), + onPressed: isDefault ? null : onSetDefault, + ), TextButton( onPressed: onEdit, child: const Text('编辑', style: TextStyle(fontSize: 12)), ), TextButton( onPressed: onDelete, - child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)), + child: const Text('删除', + style: TextStyle(fontSize: 12, color: AppTheme.danger)), ), ], ), ); } - Future _confirmDelete(String message, Future Function() onConfirmed) async { + /// 当前生效的默认 id:乐观覆盖优先,否则取店配置(保证点一下立刻变色)。 + int? _effectiveDefault(String key, int? fromShop) => + _optDefaults[key] ?? fromShop; + + /// 把某个选项写进店级配置 custom_fields 作为入库录入默认值(保留店其它信息)。 + /// 先乐观更新本地(星星即时变色),再落库;失败回滚。 + Future _setDefaultOption(String key, int id) async { + final prev = _optDefaults[key]; + setState(() => _optDefaults[key] = id); // 即时变色 + try { + final shop = await ref.read(shopInfoProvider.future); + final cf = Map.from(shop.customFields)..[key] = id; + await ref.read(shopRepositoryProvider).updateInfo({ + 'name': shop.name, + 'address': shop.address, + 'phone': shop.phone, + 'manager_name': shop.managerName, + 'wechat_id': shop.wechatId, + if (shop.logoUrl.isNotEmpty) 'logo_url': shop.logoUrl, + 'custom_fields': cf, + }); + ref.invalidate(shopInfoProvider); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('已设为默认'))); + } + } catch (e) { + if (mounted) { + setState(() { + if (prev == null) { + _optDefaults.remove(key); // 回滚 + } else { + _optDefaults[key] = prev; + } + }); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('设置失败:$e'))); + } + } + } + + Future _confirmDelete( + String message, Future Function() onConfirmed) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('确认删除'), content: Text(message), actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), ElevatedButton( onPressed: () => Navigator.of(ctx).pop(true), - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), child: const Text('删除'), ), ], @@ -481,7 +720,8 @@ class _ProductsScreenState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('删除失败:$e'), backgroundColor: AppTheme.danger), + SnackBar( + content: Text('删除失败:$e'), backgroundColor: AppTheme.danger), ); } } @@ -494,20 +734,30 @@ class _ProductsScreenState extends ConsumerState { final async = ref.watch(productOriginListProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => _buildError(() => ref.read(productOriginListProvider.notifier).reload()), + error: (e, _) => _buildError( + () => ref.read(productOriginListProvider.notifier).reload()), data: (items) { final keyword = _originSearchCtrl.text.toLowerCase(); final filtered = keyword.isEmpty ? items - : items.where((it) => it.name.toLowerCase().contains(keyword) || - (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); - final paged = filtered.skip((_originPage - 1) * _originPageSize).take(_originPageSize).toList(); + : items + .where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)) + .toList(); + final paged = filtered + .skip((_originPage - 1) * _originPageSize) + .take(_originPageSize) + .toList(); return DataTableCard( totalCount: filtered.length, page: _originPage, pageSize: _originPageSize, onPageChanged: (p) => setState(() => _originPage = p), - onPageSizeChanged: (s) => setState(() { _originPageSize = s; _originPage = 1; }), + onPageSizeChanged: (s) => setState(() { + _originPageSize = s; + _originPage = 1; + }), toolbar: _buildToolbar( searchCtrl: _originSearchCtrl, hint: '搜索产地/编号', @@ -515,41 +765,89 @@ class _ProductsScreenState extends ConsumerState { onAdd: () => _showOptionDialog( title: '新建产地', hasQuantity: false, - onSave: (data) => ref.read(productOriginListProvider.notifier).create(data), + onSave: (data) => + ref.read(productOriginListProvider.notifier).create(data), ), onExport: () => exportExcel( filename: '产地', headers: ['编号', '名称', '备注'], - rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(), + rows: items + .map((it) => [it.code ?? '', it.name, it.remark ?? '']) + .toList(), ), ), - mobileCards: paged.map((it) => _optionCard( - code: it.code, name: it.name, remark: it.remark, - onEdit: () => _showOptionDialog( - title: '编辑产地', hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productOriginListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete('删除产地「${it.name}」?', - () => ref.read(productOriginListProvider.notifier).delete(it.id)), - )).toList(), - columns: const [DataColumn(label: Text('编号')), DataColumn(label: Text('名称')), DataColumn(label: Text('备注')), DataColumn(label: Text('操作'))], + mobileCards: paged + .map((it) => _optionCard( + code: it.code, + name: it.name, + remark: it.remark, + onEdit: () => _showOptionDialog( + title: '编辑产地', + hasQuantity: false, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productOriginListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除产地「${it.name}」?', + () => ref + .read(productOriginListProvider.notifier) + .delete(it.id)), + )) + .toList(), + columns: const [ + DataColumn(label: Text('编号')), + DataColumn(label: Text('名称')), + DataColumn(label: Text('备注')), + DataColumn(label: Text('操作')) + ], rows: paged.isEmpty - ? [DataRow(cells: [const DataCell(SizedBox()), const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), const DataCell(SizedBox()), const DataCell(SizedBox())])] - : paged.map((it) => DataRow(cells: [ - DataCell(Text(it.code ?? '-', style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), - DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(it.remark ?? '-')), - DataCell(_actionButtons( - onEdit: () => _showOptionDialog( - title: '编辑产地', hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productOriginListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete('删除产地「${it.name}」?', - () => ref.read(productOriginListProvider.notifier).delete(it.id)), - )), - ])).toList(), + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', + style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()) + ]) + ] + : paged + .map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(Text(it.name, + style: + const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(it.remark ?? '-')), + DataCell(_actionButtons( + onEdit: () => _showOptionDialog( + title: '编辑产地', + hasQuantity: false, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productOriginListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除产地「${it.name}」?', + () => ref + .read(productOriginListProvider.notifier) + .delete(it.id)), + )), + ])) + .toList(), ); }, ); @@ -561,20 +859,30 @@ class _ProductsScreenState extends ConsumerState { final async = ref.watch(productShelfLifeListProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => _buildError(() => ref.read(productShelfLifeListProvider.notifier).reload()), + error: (e, _) => _buildError( + () => ref.read(productShelfLifeListProvider.notifier).reload()), data: (items) { final keyword = _shelfLifeSearchCtrl.text.toLowerCase(); final filtered = keyword.isEmpty ? items - : items.where((it) => it.name.toLowerCase().contains(keyword) || - (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); - final paged = filtered.skip((_shelfLifePage - 1) * _shelfLifePageSize).take(_shelfLifePageSize).toList(); + : items + .where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)) + .toList(); + final paged = filtered + .skip((_shelfLifePage - 1) * _shelfLifePageSize) + .take(_shelfLifePageSize) + .toList(); return DataTableCard( totalCount: filtered.length, page: _shelfLifePage, pageSize: _shelfLifePageSize, onPageChanged: (p) => setState(() => _shelfLifePage = p), - onPageSizeChanged: (s) => setState(() { _shelfLifePageSize = s; _shelfLifePage = 1; }), + onPageSizeChanged: (s) => setState(() { + _shelfLifePageSize = s; + _shelfLifePage = 1; + }), toolbar: _buildToolbar( searchCtrl: _shelfLifeSearchCtrl, hint: '搜索保质期/编号', @@ -582,41 +890,89 @@ class _ProductsScreenState extends ConsumerState { onAdd: () => _showOptionDialog( title: '新建保质期', hasQuantity: false, - onSave: (data) => ref.read(productShelfLifeListProvider.notifier).create(data), + onSave: (data) => + ref.read(productShelfLifeListProvider.notifier).create(data), ), onExport: () => exportExcel( filename: '保质期', headers: ['编号', '名称', '备注'], - rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(), + rows: items + .map((it) => [it.code ?? '', it.name, it.remark ?? '']) + .toList(), ), ), - mobileCards: paged.map((it) => _optionCard( - code: it.code, name: it.name, remark: it.remark, - onEdit: () => _showOptionDialog( - title: '编辑保质期', hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productShelfLifeListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete('删除保质期「${it.name}」?', - () => ref.read(productShelfLifeListProvider.notifier).delete(it.id)), - )).toList(), - columns: const [DataColumn(label: Text('编号')), DataColumn(label: Text('名称')), DataColumn(label: Text('备注')), DataColumn(label: Text('操作'))], + mobileCards: paged + .map((it) => _optionCard( + code: it.code, + name: it.name, + remark: it.remark, + onEdit: () => _showOptionDialog( + title: '编辑保质期', + hasQuantity: false, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productShelfLifeListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除保质期「${it.name}」?', + () => ref + .read(productShelfLifeListProvider.notifier) + .delete(it.id)), + )) + .toList(), + columns: const [ + DataColumn(label: Text('编号')), + DataColumn(label: Text('名称')), + DataColumn(label: Text('备注')), + DataColumn(label: Text('操作')) + ], rows: paged.isEmpty - ? [DataRow(cells: [const DataCell(SizedBox()), const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), const DataCell(SizedBox()), const DataCell(SizedBox())])] - : paged.map((it) => DataRow(cells: [ - DataCell(Text(it.code ?? '-', style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), - DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(it.remark ?? '-')), - DataCell(_actionButtons( - onEdit: () => _showOptionDialog( - title: '编辑保质期', hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productShelfLifeListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete('删除保质期「${it.name}」?', - () => ref.read(productShelfLifeListProvider.notifier).delete(it.id)), - )), - ])).toList(), + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', + style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()) + ]) + ] + : paged + .map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(Text(it.name, + style: + const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(it.remark ?? '-')), + DataCell(_actionButtons( + onEdit: () => _showOptionDialog( + title: '编辑保质期', + hasQuantity: false, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productShelfLifeListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除保质期「${it.name}」?', + () => ref + .read(productShelfLifeListProvider.notifier) + .delete(it.id)), + )), + ])) + .toList(), ); }, ); @@ -628,20 +984,30 @@ class _ProductsScreenState extends ConsumerState { final async = ref.watch(productStorageListProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => _buildError(() => ref.read(productStorageListProvider.notifier).reload()), + error: (e, _) => _buildError( + () => ref.read(productStorageListProvider.notifier).reload()), data: (items) { final keyword = _storageSearchCtrl.text.toLowerCase(); final filtered = keyword.isEmpty ? items - : items.where((it) => it.name.toLowerCase().contains(keyword) || - (it.code?.toLowerCase().contains(keyword) ?? false)).toList(); - final paged = filtered.skip((_storagePage - 1) * _storagePageSize).take(_storagePageSize).toList(); + : items + .where((it) => + it.name.toLowerCase().contains(keyword) || + (it.code?.toLowerCase().contains(keyword) ?? false)) + .toList(); + final paged = filtered + .skip((_storagePage - 1) * _storagePageSize) + .take(_storagePageSize) + .toList(); return DataTableCard( totalCount: filtered.length, page: _storagePage, pageSize: _storagePageSize, onPageChanged: (p) => setState(() => _storagePage = p), - onPageSizeChanged: (s) => setState(() { _storagePageSize = s; _storagePage = 1; }), + onPageSizeChanged: (s) => setState(() { + _storagePageSize = s; + _storagePage = 1; + }), toolbar: _buildToolbar( searchCtrl: _storageSearchCtrl, hint: '搜索储存方式/编号', @@ -649,41 +1015,89 @@ class _ProductsScreenState extends ConsumerState { onAdd: () => _showOptionDialog( title: '新建储存方式', hasQuantity: false, - onSave: (data) => ref.read(productStorageListProvider.notifier).create(data), + onSave: (data) => + ref.read(productStorageListProvider.notifier).create(data), ), onExport: () => exportExcel( filename: '储存方式', headers: ['编号', '名称', '备注'], - rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(), + rows: items + .map((it) => [it.code ?? '', it.name, it.remark ?? '']) + .toList(), ), ), - mobileCards: paged.map((it) => _optionCard( - code: it.code, name: it.name, remark: it.remark, - onEdit: () => _showOptionDialog( - title: '编辑储存方式', hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productStorageListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete('删除储存方式「${it.name}」?', - () => ref.read(productStorageListProvider.notifier).delete(it.id)), - )).toList(), - columns: const [DataColumn(label: Text('编号')), DataColumn(label: Text('名称')), DataColumn(label: Text('备注')), DataColumn(label: Text('操作'))], + mobileCards: paged + .map((it) => _optionCard( + code: it.code, + name: it.name, + remark: it.remark, + onEdit: () => _showOptionDialog( + title: '编辑储存方式', + hasQuantity: false, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productStorageListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除储存方式「${it.name}」?', + () => ref + .read(productStorageListProvider.notifier) + .delete(it.id)), + )) + .toList(), + columns: const [ + DataColumn(label: Text('编号')), + DataColumn(label: Text('名称')), + DataColumn(label: Text('备注')), + DataColumn(label: Text('操作')) + ], rows: paged.isEmpty - ? [DataRow(cells: [const DataCell(SizedBox()), const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), const DataCell(SizedBox()), const DataCell(SizedBox())])] - : paged.map((it) => DataRow(cells: [ - DataCell(Text(it.code ?? '-', style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), - DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(it.remark ?? '-')), - DataCell(_actionButtons( - onEdit: () => _showOptionDialog( - title: '编辑储存方式', hasQuantity: false, - initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productStorageListProvider.notifier).updateItem(it.id, data), - ), - onDelete: () => _confirmDelete('删除储存方式「${it.name}」?', - () => ref.read(productStorageListProvider.notifier).delete(it.id)), - )), - ])).toList(), + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', + style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()) + ]) + ] + : paged + .map((it) => DataRow(cells: [ + DataCell(Text(it.code ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(Text(it.name, + style: + const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(it.remark ?? '-')), + DataCell(_actionButtons( + onEdit: () => _showOptionDialog( + title: '编辑储存方式', + hasQuantity: false, + initial: { + 'code': it.code ?? '', + 'name': it.name, + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productStorageListProvider.notifier) + .updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除储存方式「${it.name}」?', + () => ref + .read(productStorageListProvider.notifier) + .delete(it.id)), + )), + ])) + .toList(), ); }, ); @@ -695,76 +1109,128 @@ class _ProductsScreenState extends ConsumerState { final async = ref.watch(productDescriptionDocListProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => _buildError(() => ref.read(productDescriptionDocListProvider.notifier).reload()), + error: (e, _) => _buildError( + () => ref.read(productDescriptionDocListProvider.notifier).reload()), data: (items) { final keyword = _descDocSearchCtrl.text.toLowerCase(); final filtered = keyword.isEmpty ? items - : items.where((it) => it.title.toLowerCase().contains(keyword)).toList(); - final paged = filtered.skip((_descDocPage - 1) * _descDocPageSize).take(_descDocPageSize).toList(); + : items + .where((it) => it.title.toLowerCase().contains(keyword)) + .toList(); + final paged = filtered + .skip((_descDocPage - 1) * _descDocPageSize) + .take(_descDocPageSize) + .toList(); return DataTableCard( totalCount: filtered.length, page: _descDocPage, pageSize: _descDocPageSize, onPageChanged: (p) => setState(() => _descDocPage = p), - onPageSizeChanged: (s) => setState(() { _descDocPageSize = s; _descDocPage = 1; }), + onPageSizeChanged: (s) => setState(() { + _descDocPageSize = s; + _descDocPage = 1; + }), toolbar: _buildToolbar( searchCtrl: _descDocSearchCtrl, hint: '搜索标题', onSearchChanged: () => setState(() => _descDocPage = 1), onAdd: () => _showDescDocDialog( title: '新建描述文档', - onSave: (data) => ref.read(productDescriptionDocListProvider.notifier).create(data), + onSave: (data) => ref + .read(productDescriptionDocListProvider.notifier) + .create(data), ), onExport: () => exportExcel( filename: '描述文档', headers: ['标题', '内容', '备注'], - rows: items.map((it) => [it.title, it.content ?? '', it.remark ?? '']).toList(), + rows: items + .map((it) => [it.title, it.content ?? '', it.remark ?? '']) + .toList(), ), ), - mobileCards: paged.map((it) => MobileListCard( - title: Text(it.title), - fields: [ - if (it.content?.isNotEmpty == true) - MobileCardField('内容', it.content), - if (it.remark?.isNotEmpty == true) - MobileCardField('备注', it.remark), - ], - actions: [ - TextButton( - onPressed: () => _showDescDocDialog( - title: '编辑描述文档', - initial: {'title': it.title, 'content': it.content ?? '', 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productDescriptionDocListProvider.notifier).updateItem(it.id, data), - ), - child: const Text('编辑', style: TextStyle(fontSize: 13)), - ), - TextButton( - onPressed: () => _confirmDelete('删除描述文档「${it.title}」?', - () => ref.read(productDescriptionDocListProvider.notifier).delete(it.id)), - child: const Text('删除', style: TextStyle(fontSize: 13, color: AppTheme.danger)), - ), - ], - )).toList(), - columns: const [DataColumn(label: Text('标题')), DataColumn(label: Text('内容预览')), DataColumn(label: Text('备注')), DataColumn(label: Text('操作'))], + mobileCards: paged + .map((it) => MobileListCard( + title: Text(it.title), + fields: [ + if (it.content?.isNotEmpty == true) + MobileCardField('内容', it.content), + if (it.remark?.isNotEmpty == true) + MobileCardField('备注', it.remark), + ], + actions: [ + TextButton( + onPressed: () => _showDescDocDialog( + title: '编辑描述文档', + initial: { + 'title': it.title, + 'content': it.content ?? '', + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productDescriptionDocListProvider.notifier) + .updateItem(it.id, data), + ), + child: const Text('编辑', style: TextStyle(fontSize: 13)), + ), + TextButton( + onPressed: () => _confirmDelete( + '删除描述文档「${it.title}」?', + () => ref + .read( + productDescriptionDocListProvider.notifier) + .delete(it.id)), + child: const Text('删除', + style: TextStyle( + fontSize: 13, color: AppTheme.danger)), + ), + ], + )) + .toList(), + columns: const [ + DataColumn(label: Text('标题')), + DataColumn(label: Text('内容预览')), + DataColumn(label: Text('备注')), + DataColumn(label: Text('操作')) + ], rows: paged.isEmpty - ? [DataRow(cells: [const DataCell(SizedBox()), const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), const DataCell(SizedBox()), const DataCell(SizedBox())])] + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无数据', + style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()) + ]) + ] : paged.map((it) { final preview = (it.content ?? '').length > 30 ? '${it.content!.substring(0, 30)}…' : (it.content ?? '-'); return DataRow(cells: [ - DataCell(Text(it.title, style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(preview, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), + DataCell(Text(it.title, + style: const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(preview, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary))), DataCell(Text(it.remark ?? '-')), DataCell(_actionButtons( onEdit: () => _showDescDocDialog( title: '编辑描述文档', - initial: {'title': it.title, 'content': it.content ?? '', 'remark': it.remark ?? ''}, - onSave: (data) => ref.read(productDescriptionDocListProvider.notifier).updateItem(it.id, data), + initial: { + 'title': it.title, + 'content': it.content ?? '', + 'remark': it.remark ?? '' + }, + onSave: (data) => ref + .read(productDescriptionDocListProvider.notifier) + .updateItem(it.id, data), ), - onDelete: () => _confirmDelete('删除描述文档「${it.title}」?', - () => ref.read(productDescriptionDocListProvider.notifier).delete(it.id)), + onDelete: () => _confirmDelete( + '删除描述文档「${it.title}」?', + () => ref + .read(productDescriptionDocListProvider.notifier) + .delete(it.id)), )), ]); }).toList(), @@ -778,9 +1244,12 @@ class _ProductsScreenState extends ConsumerState { required Future Function(Map) onSave, Map? initial, }) async { - final titleCtrl = TextEditingController(text: initial?['title'] as String? ?? ''); - final contentCtrl = TextEditingController(text: initial?['content'] as String? ?? ''); - final remarkCtrl = TextEditingController(text: initial?['remark'] as String? ?? ''); + final titleCtrl = + TextEditingController(text: initial?['title'] as String? ?? ''); + final contentCtrl = + TextEditingController(text: initial?['content'] as String? ?? ''); + final remarkCtrl = + TextEditingController(text: initial?['remark'] as String? ?? ''); final formKey = GlobalKey(); await showAppDialog( @@ -797,7 +1266,8 @@ class _ProductsScreenState extends ConsumerState { TextFormField( controller: titleCtrl, decoration: const InputDecoration(labelText: '标题 *(如:酱香型白酒)'), - validator: (v) => (v == null || v.trim().isEmpty) ? '请输入标题' : null, + validator: (v) => + (v == null || v.trim().isEmpty) ? '请输入标题' : null, ), const SizedBox(height: 12), TextFormField( @@ -817,14 +1287,18 @@ class _ProductsScreenState extends ConsumerState { ), ), actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('取消')), ElevatedButton( onPressed: () async { if (!formKey.currentState!.validate()) return; final data = { 'title': titleCtrl.text.trim(), - if (contentCtrl.text.isNotEmpty) 'content': contentCtrl.text.trim(), - if (remarkCtrl.text.isNotEmpty) 'remark': remarkCtrl.text.trim(), + if (contentCtrl.text.isNotEmpty) + 'content': contentCtrl.text.trim(), + if (remarkCtrl.text.isNotEmpty) + 'remark': remarkCtrl.text.trim(), }; Navigator.of(ctx).pop(); try { @@ -832,7 +1306,9 @@ class _ProductsScreenState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('保存失败:$e'), backgroundColor: AppTheme.danger), + SnackBar( + content: Text('保存失败:$e'), + backgroundColor: AppTheme.danger), ); } } @@ -850,7 +1326,8 @@ class _ProductsScreenState extends ConsumerState { final asyncWarehouses = ref.watch(warehouseListProvider); return asyncWarehouses.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => _buildError(() => ref.read(warehouseListProvider.notifier).reload()), + error: (e, _) => + _buildError(() => ref.read(warehouseListProvider.notifier).reload()), data: (warehouses) => DataTableCard( totalCount: warehouses.length, page: 1, @@ -869,7 +1346,8 @@ class _ProductsScreenState extends ConsumerState { mobileCards: warehouses .map((w) => MobileListCard( title: Text(w.name), - subtitle: w.location?.isNotEmpty == true ? Text(w.location!) : null, + subtitle: + w.location?.isNotEmpty == true ? Text(w.location!) : null, fields: [ if (w.isDefault) const MobileCardField('默认仓库', '是'), ], @@ -881,7 +1359,8 @@ class _ProductsScreenState extends ConsumerState { TextButton( onPressed: () => _confirmDeleteWarehouse(w), child: const Text('删除', - style: TextStyle(fontSize: 13, color: AppTheme.danger)), + style: + TextStyle(fontSize: 13, color: AppTheme.danger)), ), ], )) @@ -893,18 +1372,23 @@ class _ProductsScreenState extends ConsumerState { DataColumn(label: Text('操作')), ], rows: warehouses.isEmpty - ? [const DataRow(cells: [ - DataCell(SizedBox()), - DataCell(Text('暂无仓库', style: TextStyle(color: AppTheme.textSecondary))), - DataCell(SizedBox()), - DataCell(SizedBox()), - ])] + ? [ + const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无仓库', + style: TextStyle(color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] : warehouses .map((w) => DataRow(cells: [ - DataCell(Text(w.name, style: const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(w.name, + style: const TextStyle(fontWeight: FontWeight.w500))), DataCell(Text(w.location ?? '-')), DataCell(w.isDefault - ? const Icon(Icons.check_circle, color: AppTheme.success, size: 18) + ? const Icon(Icons.check_circle, + color: AppTheme.success, size: 18) : const SizedBox()), DataCell(_actionButtons( onEdit: () => _showWarehouseDialog(warehouse: w), @@ -939,7 +1423,8 @@ class _ProductsScreenState extends ConsumerState { ElevatedButton( onPressed: () => Navigator.of(ctx).pop(true), style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), child: const Text('删除'), ), ], @@ -949,13 +1434,13 @@ class _ProductsScreenState extends ConsumerState { try { await ref.read(warehouseListProvider.notifier).deleteWarehouse(w.id); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('删除成功'), backgroundColor: AppTheme.success)); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('删除成功'), backgroundColor: AppTheme.success)); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('删除失败:$e'), backgroundColor: AppTheme.danger)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('删除失败:$e'), backgroundColor: AppTheme.danger)); } } } @@ -967,13 +1452,16 @@ class _ProductsScreenState extends ConsumerState { required Future Function(Map) onSave, Map? initial, }) async { - final codeCtrl = TextEditingController(text: initial?['code'] as String? ?? ''); - final nameCtrl = TextEditingController(text: initial?['name'] as String? ?? ''); + final codeCtrl = + TextEditingController(text: initial?['code'] as String? ?? ''); + final nameCtrl = + TextEditingController(text: initial?['name'] as String? ?? ''); final quantityCtrl = TextEditingController( text: initial != null && (initial['quantity'] as int? ?? 0) > 0 ? '${initial['quantity']}' : ''); - final remarkCtrl = TextEditingController(text: initial?['remark'] as String? ?? ''); + final remarkCtrl = + TextEditingController(text: initial?['remark'] as String? ?? ''); final formKey = GlobalKey(); await showAppDialog( @@ -995,7 +1483,8 @@ class _ProductsScreenState extends ConsumerState { TextFormField( controller: nameCtrl, decoration: const InputDecoration(labelText: '名称 *'), - validator: (v) => (v == null || v.trim().isEmpty) ? '请输入名称' : null, + validator: (v) => + (v == null || v.trim().isEmpty) ? '请输入名称' : null, ), if (hasQuantity) ...[ const SizedBox(height: 12), @@ -1016,7 +1505,9 @@ class _ProductsScreenState extends ConsumerState { ), ), actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('取消')), ElevatedButton( onPressed: () async { if (!formKey.currentState!.validate()) return; @@ -1025,7 +1516,8 @@ class _ProductsScreenState extends ConsumerState { if (codeCtrl.text.isNotEmpty) 'code': codeCtrl.text.trim(), if (hasQuantity && quantityCtrl.text.isNotEmpty) 'quantity': int.tryParse(quantityCtrl.text) ?? 0, - if (remarkCtrl.text.isNotEmpty) 'remark': remarkCtrl.text.trim(), + if (remarkCtrl.text.isNotEmpty) + 'remark': remarkCtrl.text.trim(), }; Navigator.of(ctx).pop(); try { @@ -1033,7 +1525,9 @@ class _ProductsScreenState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('创建失败:$e'), backgroundColor: AppTheme.danger), + SnackBar( + content: Text('创建失败:$e'), + backgroundColor: AppTheme.danger), ); } } @@ -1053,7 +1547,8 @@ class _WarehouseFormDialog extends ConsumerStatefulWidget { const _WarehouseFormDialog({this.warehouse, required this.onSaved}); @override - ConsumerState<_WarehouseFormDialog> createState() => _WarehouseFormDialogState(); + ConsumerState<_WarehouseFormDialog> createState() => + _WarehouseFormDialogState(); } class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { @@ -1067,7 +1562,8 @@ class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { void initState() { super.initState(); _nameCtrl = TextEditingController(text: widget.warehouse?.name ?? ''); - _locationCtrl = TextEditingController(text: widget.warehouse?.location ?? ''); + _locationCtrl = + TextEditingController(text: widget.warehouse?.location ?? ''); _isDefault = widget.warehouse?.isDefault ?? false; } @@ -1083,7 +1579,8 @@ class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { setState(() => _saving = true); final data = { 'name': _nameCtrl.text.trim(), - if (_locationCtrl.text.trim().isNotEmpty) 'location': _locationCtrl.text.trim(), + if (_locationCtrl.text.trim().isNotEmpty) + 'location': _locationCtrl.text.trim(), 'is_default': _isDefault, }; try { @@ -1103,8 +1600,8 @@ class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('保存失败:$e'), backgroundColor: AppTheme.danger)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('保存失败:$e'), backgroundColor: AppTheme.danger)); } } finally { if (mounted) setState(() => _saving = false); diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index b20eb39..3bbcf6f 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -20,8 +20,10 @@ import '../../providers/license_provider.dart'; import '../../widgets/network_retry_button.dart'; class AppShell extends ConsumerStatefulWidget { - final Widget child; - const AppShell({super.key, required this.child}); + /// StatefulShellRoute 注入的导航壳:本身是各栏目分支 Navigator 的 IndexedStack, + /// 跨栏目切换时各分支页面 State 常驻(保住半填表单 / 内部 tab / 滚动位置)。 + final StatefulNavigationShell navigationShell; + const AppShell({super.key, required this.navigationShell}); @override ConsumerState createState() => _AppShellState(); @@ -29,13 +31,21 @@ class AppShell extends ConsumerStatefulWidget { class _AppShellState extends ConsumerState { bool _sidebarExpanded = true; + + /// 切换到第 index 个分支;再次点当前栏目则回到该分支初始页。 + void _goBranch(int index) { + widget.navigationShell.goBranch( + index, + initialLocation: index == widget.navigationShell.currentIndex, + ); + } + final String _loginTime = DateFormat('HH:mm:ss').format(DateTime.now()); bool _forceDialogShown = false; bool _licenseDialogShown = false; final GlobalKey _scaffoldKey = GlobalKey(); - void _showForceUpdateDialog( - BuildContext context, AppUpdateInfo info) { + void _showForceUpdateDialog(BuildContext context, AppUpdateInfo info) { if (_forceDialogShown) return; _forceDialogShown = true; showAppDialog( @@ -69,9 +79,7 @@ class _AppShellState extends ConsumerState { _NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'), _NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'), _NavItem( - icon: Icons.account_balance_wallet, - label: '财务管理', - path: '/finance'), + icon: Icons.account_balance_wallet, label: '财务管理', path: '/finance'), _NavItem(icon: Icons.people, label: '往来单位', path: '/partners'), _NavItem(icon: Icons.category, label: '基础数据', path: '/products'), _NavItem(icon: Icons.devices, label: '设备管理', path: '/devices'), @@ -80,8 +88,7 @@ class _AppShellState extends ConsumerState { ]; /// 窄屏(手机)侧滑抽屉导航,容纳全部菜单项。 - Widget _buildDrawer( - BuildContext context, AuthUser? user, String location) { + Widget _buildDrawer(BuildContext context, AuthUser? user) { final shopAsync = ref.watch(shopInfoProvider); final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? ''; final logoUrl = shopAsync.valueOrNull?.logoUrl ?? ''; @@ -98,8 +105,7 @@ class _AppShellState extends ConsumerState { color: AppTheme.primary, child: Row( children: [ - _ShopLogo( - logoUrl: logoUrl, shopName: shopName, size: 40), + _ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 40), const SizedBox(width: 12), Expanded( child: Column( @@ -132,15 +138,16 @@ class _AppShellState extends ConsumerState { Expanded( child: ListView( padding: const EdgeInsets.symmetric(vertical: 8), - children: _navItems.map((item) { - final isActive = location.startsWith(item.path); + children: _navItems.asMap().entries.map((e) { + final isActive = + widget.navigationShell.currentIndex == e.key; return _SidebarItem( - item: item, + item: e.value, isActive: isActive, expanded: true, onTap: () { Navigator.pop(context); // 关闭抽屉 - context.go(item.path); + _goBranch(e.key); }, ); }).toList(), @@ -180,390 +187,383 @@ class _AppShellState extends ConsumerState { ref.read(apiMessageProvider.notifier).state = null; }); final isOnline = ref.watch(connectivityProvider); - final location = GoRouterState.of(context).matchedLocation; final isMobile = context.isMobile; // iOS/刘海屏状态栏高度:顶栏需下移此距离,避免菜单按钮被状态栏遮挡 final topInset = MediaQuery.of(context).padding.top; final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0; final updateNotifier = ref.watch(updateProvider.notifier); final updateInfo = ref.watch(updateProvider).valueOrNull; - final appVersion = - ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; + final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; final licenseInfo = ref.watch(licenseProvider).valueOrNull; return SelectionArea( child: Scaffold( - key: _scaffoldKey, - drawer: isMobile ? _buildDrawer(context, user, location) : null, - drawerEnableOpenDragGesture: !kIsWeb && isMobile, - body: Column( - children: [ - // Top Bar(高度含状态栏内边距,蓝色铺满到屏幕顶,内容下移避开状态栏) - Container( - height: 56 + topInset, - color: AppTheme.primary, - padding: EdgeInsets.only(top: topInset, left: 8, right: 8), - child: Row( - children: [ - IconButton( - icon: Icon( - isMobile - ? Icons.menu - : (_sidebarExpanded ? Icons.menu_open : Icons.menu), - color: Colors.white), - onPressed: () { - if (isMobile) { - _scaffoldKey.currentState?.openDrawer(); - } else { - setState(() => _sidebarExpanded = !_sidebarExpanded); - } - }, - tooltip: isMobile - ? '菜单' - : (_sidebarExpanded ? '收起侧边栏' : '展开侧边栏'), - ), - const SizedBox(width: 4), - _ShopButton(user: user, version: appVersion), - if (WriteGuard.isReadonly(ref)) ...[ - const SizedBox(width: 8), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(4), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.visibility_outlined, - size: 13, color: Colors.white), - SizedBox(width: 4), - Text('只读', - style: TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600)), - ], - ), - ), - ], - const Spacer(), - if (user != null) ...[ - // 门店号已移除;用户名移到左侧栏底部。顶栏仅保留「个人设置」下拉。 - PopupMenuButton( - icon: const Icon(Icons.keyboard_arrow_down, - color: Colors.white70), - offset: const Offset(0, 36), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6)), - color: Colors.white, - elevation: 8, - onSelected: (v) { - if (v == 'logout') { - ref.read(authStateProvider.notifier).logout(); - context.go('/login'); + key: _scaffoldKey, + drawer: isMobile ? _buildDrawer(context, user) : null, + drawerEnableOpenDragGesture: !kIsWeb && isMobile, + body: Column( + children: [ + // Top Bar(高度含状态栏内边距,蓝色铺满到屏幕顶,内容下移避开状态栏) + Container( + height: 56 + topInset, + color: AppTheme.primary, + padding: EdgeInsets.only(top: topInset, left: 8, right: 8), + child: Row( + children: [ + IconButton( + icon: Icon( + isMobile + ? Icons.menu + : (_sidebarExpanded ? Icons.menu_open : Icons.menu), + color: Colors.white), + onPressed: () { + if (isMobile) { + _scaffoldKey.currentState?.openDrawer(); + } else { + setState(() => _sidebarExpanded = !_sidebarExpanded); } }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'profile', - padding: EdgeInsets.zero, - child: _HoverMenuItem( - icon: Icons.manage_accounts_outlined, - label: '个人设置', - ), - ), - ], + tooltip: isMobile + ? '菜单' + : (_sidebarExpanded ? '收起侧边栏' : '展开侧边栏'), ), - const SizedBox(width: 8), - ], - ], - ), - ), - // Main area - Expanded( - child: Row( - children: [ - // Sidebar(仅宽屏;窄屏改用 Drawer) - if (!isMobile) - AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - width: sidebarWidth, - color: AppTheme.primaryDark, - child: Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: _navItems.map((item) { - final isActive = - location.startsWith(item.path); - return _SidebarItem( - item: item, - isActive: isActive, - expanded: _sidebarExpanded, - onTap: () => context.go(item.path), - ); - }).toList(), + const SizedBox(width: 4), + _ShopButton(user: user, version: appVersion), + if (WriteGuard.isReadonly(ref)) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.visibility_outlined, + size: 13, color: Colors.white), + SizedBox(width: 4), + Text('只读', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600)), + ], + ), + ), + ], + const Spacer(), + if (user != null) ...[ + // 门店号已移除;用户名移到左侧栏底部。顶栏仅保留「个人设置」下拉。 + PopupMenuButton( + icon: const Icon(Icons.keyboard_arrow_down, + color: Colors.white70), + offset: const Offset(0, 36), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6)), + color: Colors.white, + elevation: 8, + onSelected: (v) { + if (v == 'logout') { + ref.read(authStateProvider.notifier).logout(); + context.go('/login'); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'profile', + padding: EdgeInsets.zero, + child: _HoverMenuItem( + icon: Icons.manage_accounts_outlined, + label: '个人设置', ), ), - // 当前登录账号:点击弹下拉菜单(退出登录) - if (user != null) ...[ - const Divider(height: 1, color: Colors.white24), - PopupMenuButton( - tooltip: '账号菜单', - position: PopupMenuPosition.over, - offset: const Offset(0, -8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6)), - color: Colors.white, - elevation: 8, - onSelected: (v) { - if (v == 'logout') { - ref - .read(authStateProvider.notifier) - .logout(); - context.go('/login'); - } - }, - itemBuilder: (context) => const [ - PopupMenuItem( - value: 'logout', - padding: EdgeInsets.zero, - child: _HoverMenuItem( - icon: Icons.logout, - label: '退出登录', - ), - ), - ], - child: SizedBox( - height: 48, - child: Row( - children: [ - SizedBox(width: _sidebarExpanded ? 16 : 3), - Expanded( - child: Row( - mainAxisAlignment: _sidebarExpanded - ? MainAxisAlignment.start - : MainAxisAlignment.center, - children: [ - const Icon(Icons.person_outline, - color: Colors.white60, size: 20), - if (_sidebarExpanded) ...[ - const SizedBox(width: 12), - Expanded( - child: Text( - user.username, - style: const TextStyle( - color: Colors.white70, - fontSize: 14), - overflow: TextOverflow.ellipsis, - ), - ), - const Icon( - Icons.keyboard_arrow_up, - color: Colors.white38, - size: 18), - const SizedBox(width: 12), - ], - ], - ), - ), - ], - ), + ], + ), + const SizedBox(width: 8), + ], + ], + ), + ), + // Main area + Expanded( + child: Row( + children: [ + // Sidebar(仅宽屏;窄屏改用 Drawer) + if (!isMobile) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: sidebarWidth, + color: AppTheme.primaryDark, + child: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: _navItems.asMap().entries.map((e) { + final isActive = + widget.navigationShell.currentIndex == + e.key; + return _SidebarItem( + item: e.value, + isActive: isActive, + expanded: _sidebarExpanded, + onTap: () => _goBranch(e.key), + ); + }).toList(), ), ), + // 当前登录账号:点击弹下拉菜单(退出登录) + if (user != null) ...[ + const Divider(height: 1, color: Colors.white24), + PopupMenuButton( + tooltip: '账号菜单', + position: PopupMenuPosition.over, + offset: const Offset(0, -8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6)), + color: Colors.white, + elevation: 8, + onSelected: (v) { + if (v == 'logout') { + ref.read(authStateProvider.notifier).logout(); + context.go('/login'); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'logout', + padding: EdgeInsets.zero, + child: _HoverMenuItem( + icon: Icons.logout, + label: '退出登录', + ), + ), + ], + child: SizedBox( + height: 48, + child: Row( + children: [ + SizedBox(width: _sidebarExpanded ? 16 : 3), + Expanded( + child: Row( + mainAxisAlignment: _sidebarExpanded + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + const Icon(Icons.person_outline, + color: Colors.white60, size: 20), + if (_sidebarExpanded) ...[ + const SizedBox(width: 12), + Expanded( + child: Text( + user.username, + style: const TextStyle( + color: Colors.white70, + fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.keyboard_arrow_up, + color: Colors.white38, + size: 18), + const SizedBox(width: 12), + ], + ], + ), + ), + ], + ), + ), + ), + ], + const SizedBox(height: 8), ], - const SizedBox(height: 8), + ), + ), + // Content area + Expanded( + child: Column( + children: [ + // Update banner(非强制更新) + if (updateInfo != null && + updateInfo.hasUpdate && + !updateInfo.forceUpdate && + !updateNotifier.isDismissed) + Container( + width: double.infinity, + color: const Color(0xFFFFF8E1), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + child: Row( + children: [ + const Icon(Icons.system_update, + size: 16, color: Color(0xFFF57F17)), + const SizedBox(width: 8), + Expanded( + child: Text( + '发现新版本 v${updateInfo.latestVersion},' + '建议立即更新以获得最新功能与修复', + style: const TextStyle( + color: Color(0xFF5D4037), fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: () => + startInAppUpdate(context, updateInfo), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFF57F17)), + child: const Text('立即更新'), + ), + TextButton( + onPressed: updateNotifier.dismiss, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF9E9E9E)), + child: const Text('稍后再说'), + ), + ], + ), + ), + // 强制更新 dialog(用 postFrameCallback 避免 build 中 showDialog) + if (updateInfo != null && + updateInfo.hasUpdate && + updateInfo.forceUpdate) + Builder(builder: (ctx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _showForceUpdateDialog(ctx, updateInfo); + }); + return const SizedBox.shrink(); + }), + // License expiry banner + if (licenseInfo != null && licenseInfo.needsAttention) + _buildLicenseBanner(licenseInfo), + // License expiry dialog (once per session) + if (licenseInfo != null && + licenseInfo.needsAttention && + !_licenseDialogShown) + Builder(builder: (ctx) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_licenseDialogShown && mounted) { + _licenseDialogShown = true; + _showLicenseExpiryDialog(ctx, licenseInfo); + } + }); + return const SizedBox.shrink(); + }), + // Offline banner + if (!isOnline) + Container( + width: double.infinity, + color: AppTheme.danger, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + child: const Row( + children: [ + Icon(Icons.wifi_off, + size: 16, color: Colors.white), + SizedBox(width: 8), + Expanded( + child: Text( + '网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500), + ), + ), + NetworkRetryButton(), + ], + ), + ), + Expanded(child: widget.navigationShell), + // Status bar(仅宽屏;窄屏隐藏,离线已有顶部横幅提示) + if (!isMobile) + LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + // Three tiers: wide / medium / narrow + final wide = w >= 650; + final medium = w >= 190; + final iconOnly = !medium; + + return Container( + height: 28, + color: isOnline + ? const Color(0xFF37474F) + : AppTheme.danger, + padding: + const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + if (!isOnline) ...[ + const Icon(Icons.wifi_off, + size: 11, color: Colors.white70), + if (!iconOnly) ...[ + const SizedBox(width: 4), + const Text('离线', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600)), + ], + const _StatusDivider(), + ], + if (isOnline && user != null) ...[ + _StatusItem( + icon: Icons.store, + text: user.shopNo, + iconOnly: iconOnly), + const _StatusDivider(), + _StatusItem( + icon: Icons.person, + text: user.username, + iconOnly: iconOnly), + if (wide) ...[ + const _StatusDivider(), + _StatusItem( + icon: Icons.login, + text: '登录时间:$_loginTime'), + const _StatusDivider(), + const _ClockWidget(), + ] else + const _StatusDivider(), + ], + const Spacer(), + _StatusItem( + icon: isOnline + ? Icons.cloud_done_outlined + : Icons.cloud_off_outlined, + text: isOnline ? '已连接' : '连接已断开', + iconOnly: iconOnly, + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.info_outline, + text: ref + .watch(appVersionProvider) + .valueOrNull ?? + 'v1.0.0', + iconOnly: iconOnly), + _LicenseStatusItem( + lic: licenseInfo, iconOnly: iconOnly), + ], + ), + ); + }, + ), ], ), ), - // Content area - Expanded( - child: Column( - children: [ - // Update banner(非强制更新) - if (updateInfo != null && - updateInfo.hasUpdate && - !updateInfo.forceUpdate && - !updateNotifier.isDismissed) - Container( - width: double.infinity, - color: const Color(0xFFFFF8E1), - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 6), - child: Row( - children: [ - const Icon(Icons.system_update, - size: 16, color: Color(0xFFF57F17)), - const SizedBox(width: 8), - Expanded( - child: Text( - '发现新版本 v${updateInfo.latestVersion},' - '建议立即更新以获得最新功能与修复', - style: const TextStyle( - color: Color(0xFF5D4037), - fontSize: 13), - overflow: TextOverflow.ellipsis, - ), - ), - TextButton( - onPressed: () => - startInAppUpdate(context, updateInfo), - style: TextButton.styleFrom( - foregroundColor: - const Color(0xFFF57F17)), - child: const Text('立即更新'), - ), - TextButton( - onPressed: updateNotifier.dismiss, - style: TextButton.styleFrom( - foregroundColor: - const Color(0xFF9E9E9E)), - child: const Text('稍后再说'), - ), - ], - ), - ), - // 强制更新 dialog(用 postFrameCallback 避免 build 中 showDialog) - if (updateInfo != null && - updateInfo.hasUpdate && - updateInfo.forceUpdate) - Builder(builder: (ctx) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _showForceUpdateDialog(ctx, updateInfo); - }); - return const SizedBox.shrink(); - }), - // License expiry banner - if (licenseInfo != null && - licenseInfo.needsAttention) - _buildLicenseBanner(licenseInfo), - // License expiry dialog (once per session) - if (licenseInfo != null && - licenseInfo.needsAttention && - !_licenseDialogShown) - Builder(builder: (ctx) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_licenseDialogShown && mounted) { - _licenseDialogShown = true; - _showLicenseExpiryDialog(ctx, licenseInfo); - } - }); - return const SizedBox.shrink(); - }), - // Offline banner - if (!isOnline) - Container( - width: double.infinity, - color: AppTheme.danger, - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 6), - child: const Row( - children: [ - Icon(Icons.wifi_off, - size: 16, color: Colors.white), - SizedBox(width: 8), - Expanded( - child: Text( - '网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新', - style: TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500), - ), - ), - NetworkRetryButton(), - ], - ), - ), - Expanded(child: widget.child), - // Status bar(仅宽屏;窄屏隐藏,离线已有顶部横幅提示) - if (!isMobile) - LayoutBuilder( - builder: (context, constraints) { - final w = constraints.maxWidth; - // Three tiers: wide / medium / narrow - final wide = w >= 650; - final medium = w >= 190; - final iconOnly = !medium; - - return Container( - height: 28, - color: isOnline - ? const Color(0xFF37474F) - : AppTheme.danger, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - if (!isOnline) ...[ - const Icon(Icons.wifi_off, - size: 11, color: Colors.white70), - if (!iconOnly) ...[ - const SizedBox(width: 4), - const Text('离线', - style: TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w600)), - ], - const _StatusDivider(), - ], - if (isOnline && user != null) ...[ - _StatusItem( - icon: Icons.store, - text: user.shopNo, - iconOnly: iconOnly), - const _StatusDivider(), - _StatusItem( - icon: Icons.person, - text: user.username, - iconOnly: iconOnly), - if (wide) ...[ - const _StatusDivider(), - _StatusItem( - icon: Icons.login, - text: '登录时间:$_loginTime'), - const _StatusDivider(), - const _ClockWidget(), - ] else - const _StatusDivider(), - ], - const Spacer(), - _StatusItem( - icon: isOnline - ? Icons.cloud_done_outlined - : Icons.cloud_off_outlined, - text: isOnline ? '已连接' : '连接已断开', - iconOnly: iconOnly, - ), - const _StatusDivider(), - _StatusItem( - icon: Icons.info_outline, - text: ref - .watch(appVersionProvider) - .valueOrNull ?? - 'v1.0.0', - iconOnly: iconOnly), - _LicenseStatusItem( - lic: licenseInfo, iconOnly: iconOnly), - ], - ), - ); - }, - ), - ], - ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), - ), - ); + ); } Widget _buildLicenseBanner(LicenseInfo lic) { @@ -630,8 +630,7 @@ class _NavItem { final IconData icon; final String label; final String path; - const _NavItem( - {required this.icon, required this.label, required this.path}); + const _NavItem({required this.icon, required this.label, required this.path}); } class _SidebarItem extends StatelessWidget { @@ -687,9 +686,8 @@ class _SidebarItem extends StatelessWidget { style: TextStyle( color: isActive ? Colors.white : Colors.white70, fontSize: 14, - fontWeight: isActive - ? FontWeight.w500 - : FontWeight.normal, + fontWeight: + isActive ? FontWeight.w500 : FontWeight.normal, ), overflow: TextOverflow.ellipsis, ), @@ -794,7 +792,8 @@ class _ClockWidgetState extends State<_ClockWidget> { super.initState(); _time = DateFormat('HH:mm:ss').format(DateTime.now()); _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() => _time = DateFormat('HH:mm:ss').format(DateTime.now())); + if (mounted) + setState(() => _time = DateFormat('HH:mm:ss').format(DateTime.now())); }); } @@ -810,63 +809,70 @@ class _ClockWidgetState extends State<_ClockWidget> { } } -void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'}) { +void _showShopPanel(BuildContext context, AuthUser u, + {String version = 'v1.0.0'}) { showAppDialog( context: context, builder: (ctx) => Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - child: SizedBox( - width: ctx.dialogWidth(360), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header - Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - decoration: const BoxDecoration( - color: AppTheme.primary, - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - child: Row( - children: [ - const Icon(Icons.store, color: Colors.white, size: 20), - const SizedBox(width: 10), - const Text('门店信息', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600)), - const Spacer(), - IconButton( - onPressed: () => Navigator.pop(ctx), - icon: const Icon(Icons.close, color: Colors.white70, size: 18), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: SizedBox( + width: ctx.dialogWidth(360), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: const BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), ), - // Info rows - Padding( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - _InfoRow(icon: Icons.tag, label: '门店编号', value: u.shopNo), - const SizedBox(height: 14), - _InfoRow(icon: Icons.person, label: '登录账号', value: u.username), - const SizedBox(height: 14), - _InfoRow(icon: Icons.badge_outlined, label: '姓名', value: u.realName), - const SizedBox(height: 14), - _InfoRow(icon: Icons.info_outline, label: '系统版本', value: version), - ], - ), + child: Row( + children: [ + const Icon(Icons.store, color: Colors.white, size: 20), + const SizedBox(width: 10), + const Text('门店信息', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600)), + const Spacer(), + IconButton( + onPressed: () => Navigator.pop(ctx), + icon: const Icon(Icons.close, + color: Colors.white70, size: 18), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], ), - ], - ), + ), + // Info rows + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _InfoRow(icon: Icons.tag, label: '门店编号', value: u.shopNo), + const SizedBox(height: 14), + _InfoRow( + icon: Icons.person, label: '登录账号', value: u.username), + const SizedBox(height: 14), + _InfoRow( + icon: Icons.badge_outlined, + label: '姓名', + value: u.realName), + const SizedBox(height: 14), + _InfoRow( + icon: Icons.info_outline, label: '系统版本', value: version), + ], + ), + ), + ], ), ), - ); + ), + ); } class _ShopButton extends ConsumerWidget { @@ -911,13 +917,16 @@ class _ShopLogo extends StatelessWidget { final String logoUrl; final String shopName; final double size; - const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32}); + const _ShopLogo( + {required this.logoUrl, required this.shopName, this.size = 32}); @override Widget build(BuildContext context) { final radius = BorderRadius.circular(size * 0.19); if (logoUrl.isNotEmpty) { - final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl'; + final fullUrl = logoUrl.startsWith('http') + ? logoUrl + : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl'; return ClipRRect( borderRadius: radius, child: Image.network( @@ -958,7 +967,8 @@ class _InfoRow extends StatelessWidget { final IconData icon; final String label; final String value; - const _InfoRow({required this.icon, required this.label, required this.value}); + const _InfoRow( + {required this.icon, required this.label, required this.value}); @override Widget build(BuildContext context) { @@ -969,8 +979,8 @@ class _InfoRow extends StatelessWidget { SizedBox( width: 72, child: Text(label, - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), + style: + const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), ), Expanded( child: Text(value, 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 631a0e3..1425174 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../core/auth/auth_state.dart'; import '../../core/utils/print_util.dart'; +import '../../core/utils/date_util.dart'; import '../../models/stock_in.dart'; import '../../providers/partner_provider.dart'; import '../../providers/product_option_provider.dart'; @@ -13,7 +14,9 @@ import '../../providers/product_provider.dart'; import '../../providers/inventory_provider.dart'; import '../../providers/stock_in_provider.dart'; import '../../providers/warehouse_provider.dart'; +import '../../providers/shop_provider.dart'; import '../../widgets/searchable_option_field.dart'; +import '../../widgets/date_picker_field.dart'; import '../../widgets/mobile_list_card.dart'; class StockInFormScreen extends ConsumerStatefulWidget { @@ -35,6 +38,11 @@ class _StockInFormScreenState extends ConsumerState { Map _inventoryMap = {}; StockInOrder? _loadedOrder; + // 新建单据时的名称/系列/规格默认值(名称仅店配置;系列/规格店配置优先,53度/500ml 兜底) + int? _defaultNameId; + int? _defaultSeriesId; + int? _defaultSpecId; + final List<_ItemRow> _items = []; bool get _isEdit => widget.editOrderId != null; @@ -46,13 +54,77 @@ class _StockInFormScreenState extends ConsumerState { _loadEditOrder(); } else { _items.add(_ItemRow()); + _initDefaults(); } } + /// 新建单据:算出系列/规格默认值并回填仍为空、未被改过的行。 + /// 优先级:店配置 default_series_id/default_spec_id(且仍存在)→ 同名 53度/500ml → + /// 相似项 contains('53')/contains('500') → 无则留空。 + Future _initDefaults() async { + try { + final shop = await ref.read(shopInfoProvider.future); + final names = await ref.read(productNameListProvider.future); + final series = await ref.read(productSeriesListProvider.future); + final specs = await ref.read(productSpecListProvider.future); + if (!mounted) return; + // 名称无「常见值」可兜底,只认店配置的默认 + _defaultNameId = (shop.defaultNameId != null && + names.any((o) => o.id == shop.defaultNameId)) + ? shop.defaultNameId + : null; + _defaultSeriesId = _pickDefault( + series.map((o) => OptionItem(id: o.id, name: o.name)).toList(), + configuredId: shop.defaultSeriesId, + exact: '53度', + fuzzy: '53', + ); + _defaultSpecId = _pickDefault( + specs.map((o) => OptionItem(id: o.id, name: o.name)).toList(), + configuredId: shop.defaultSpecId, + exact: '500ml', + fuzzy: '500', + ); + setState(() { + for (final item in _items) { + if (item.selectedNameId == null && !item.nameTouched) { + item.selectedNameId = _defaultNameId; + } + if (item.selectedSeriesId == null && !item.seriesTouched) { + item.selectedSeriesId = _defaultSeriesId; + } + if (item.selectedSpecId == null && !item.specTouched) { + item.selectedSpecId = _defaultSpecId; + } + } + }); + } catch (_) { + // 默认值是锦上添花,取不到就静默留空 + } + } + + int? _pickDefault( + List opts, { + int? configuredId, + required String exact, + required String fuzzy, + }) { + if (configuredId != null && opts.any((o) => o.id == configuredId)) { + return configuredId; + } + final exactHit = opts.where((o) => o.name == exact).firstOrNull; + if (exactHit != null) return exactHit.id; + final fuzzyHit = opts + .where((o) => o.name.toLowerCase().contains(fuzzy.toLowerCase())) + .firstOrNull; + return fuzzyHit?.id; + } + Future _loadEditOrder() async { setState(() => _loadingEdit = true); try { - final order = await ref.read(stockInRepositoryProvider).get(widget.editOrderId!); + final order = + await ref.read(stockInRepositoryProvider).get(widget.editOrderId!); final nameOpts = await ref.read(productNameListProvider.future); final seriesOpts = await ref.read(productSeriesListProvider.future); final specOpts = await ref.read(productSpecListProvider.future); @@ -71,9 +143,14 @@ class _StockInFormScreenState extends ConsumerState { row.productId = item.productId; row.qtyCtrl.text = item.quantity.toStringAsFixed(0); row.priceCtrl.text = item.unitPrice.toStringAsFixed(2); - row.selectedNameId = nameOpts.where((o) => o.name == item.productName).firstOrNull?.id; - row.selectedSeriesId = seriesOpts.where((o) => o.name == item.productSeries).firstOrNull?.id; - row.selectedSpecId = specOpts.where((o) => o.name == item.productSpec).firstOrNull?.id; + row.selectedNameId = + nameOpts.where((o) => o.name == item.productName).firstOrNull?.id; + row.selectedSeriesId = seriesOpts + .where((o) => o.name == item.productSeries) + .firstOrNull + ?.id; + row.selectedSpecId = + specOpts.where((o) => o.name == item.productSpec).firstOrNull?.id; if (item.batchNo != null) row.batchNoCtrl.text = item.batchNo!; if (item.productionDate != null) { row.productionDateCtrl.text = item.productionDate!.length >= 10 @@ -121,13 +198,22 @@ class _StockInFormScreenState extends ConsumerState { .read(inventoryRepositoryProvider) .listInventory(warehouseId: warehouseId, pageSize: 500); setState(() { - _inventoryMap = {for (final inv in result.data.where((inv) => inv.productId != null)) inv.productId!: inv.quantity}; + _inventoryMap = { + for (final inv in result.data.where((inv) => inv.productId != null)) + inv.productId!: inv.quantity + }; }); } catch (_) {} } void _addItem() { - setState(() => _items.add(_ItemRow())); + setState(() { + final row = _ItemRow(); + row.selectedNameId = _defaultNameId; + row.selectedSeriesId = _defaultSeriesId; + row.selectedSpecId = _defaultSpecId; + _items.add(row); + }); } void _removeItem(int index) { @@ -146,18 +232,20 @@ class _StockInFormScreenState extends ConsumerState { if (!asDraft && !_formKey.currentState!.validate()) return; if (_warehouseId == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请选择入库仓库'), backgroundColor: AppTheme.danger), + const SnackBar( + content: Text('请选择入库仓库'), backgroundColor: AppTheme.danger), ); return; } if (_items.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger), + const SnackBar( + content: Text('请添加商品明细'), backgroundColor: AppTheme.danger), ); return; } - final invalidQtyIndex = _items.indexWhere( - (item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0); + final invalidQtyIndex = _items + .indexWhere((item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0); if (invalidQtyIndex >= 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -171,7 +259,8 @@ class _StockInFormScreenState extends ConsumerState { if (!asDraft) { if (_partnerId == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请选择供应商'), backgroundColor: AppTheme.danger), + const SnackBar( + content: Text('请选择供应商'), backgroundColor: AppTheme.danger), ); return; } @@ -179,25 +268,33 @@ class _StockInFormScreenState extends ConsumerState { final item = _items[i]; if (item.selectedNameId == null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('第 ${i + 1} 行请选择商品名称'), backgroundColor: AppTheme.danger), + SnackBar( + content: Text('第 ${i + 1} 行请选择商品名称'), + backgroundColor: AppTheme.danger), ); return; } if (item.selectedSeriesId == null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('第 ${i + 1} 行请选择系列'), backgroundColor: AppTheme.danger), + SnackBar( + content: Text('第 ${i + 1} 行请选择系列'), + backgroundColor: AppTheme.danger), ); return; } if (item.selectedSpecId == null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('第 ${i + 1} 行请选择规格'), backgroundColor: AppTheme.danger), + SnackBar( + content: Text('第 ${i + 1} 行请选择规格'), + backgroundColor: AppTheme.danger), ); return; } if (item.productionDateCtrl.text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('第 ${i + 1} 行请填写生产日期'), backgroundColor: AppTheme.danger), + SnackBar( + content: Text('第 ${i + 1} 行请填写生产日期'), + backgroundColor: AppTheme.danger), ); return; } @@ -213,33 +310,46 @@ class _StockInFormScreenState extends ConsumerState { for (final item in _items) { if (item.productId == null) { - final name = nameOpts.where((o) => o.id == item.selectedNameId).firstOrNull?.name ?? ''; - final series = seriesOpts.where((o) => o.id == item.selectedSeriesId).firstOrNull?.name ?? ''; - final spec = specOpts.where((o) => o.id == item.selectedSpecId).firstOrNull?.name ?? ''; + final name = nameOpts + .where((o) => o.id == item.selectedNameId) + .firstOrNull + ?.name ?? + ''; + final series = seriesOpts + .where((o) => o.id == item.selectedSeriesId) + .firstOrNull + ?.name ?? + ''; + final spec = specOpts + .where((o) => o.id == item.selectedSpecId) + .firstOrNull + ?.name ?? + ''; if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请选择商品名称'), backgroundColor: AppTheme.danger), + const SnackBar( + content: Text('请选择商品名称'), backgroundColor: AppTheme.danger), ); setState(() => _submitting = false); return; } try { - final product = await ref - .read(productRepositoryProvider) - .findOrCreate( - name: name, - series: series, - spec: spec, - originId: item.selectedOriginId, - shelfLifeId: item.selectedShelfLifeId, - storageId: item.selectedStorageId, - descriptionDocId: item.selectedDescriptionDocId, - ); + final product = + await ref.read(productRepositoryProvider).findOrCreate( + name: name, + series: series, + spec: spec, + originId: item.selectedOriginId, + shelfLifeId: item.selectedShelfLifeId, + storageId: item.selectedStorageId, + descriptionDocId: item.selectedDescriptionDocId, + ); item.productId = product.id; } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('商品查找失败:$e'), backgroundColor: AppTheme.danger), + SnackBar( + content: Text('商品查找失败:$e'), backgroundColor: AppTheme.danger), ); setState(() => _submitting = false); } @@ -266,8 +376,7 @@ class _StockInFormScreenState extends ConsumerState { final data = { 'warehouse_id': _warehouseId, if (_partnerId != null) 'partner_id': _partnerId, - 'order_date': - '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', + 'order_date': formatYmd(_orderDate), if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(), 'items': itemsData, 'status': asDraft ? 'draft' : 'pending', @@ -275,7 +384,9 @@ class _StockInFormScreenState extends ConsumerState { try { if (_isEdit) { - await ref.read(stockInRepositoryProvider).update(widget.editOrderId!, data); + await ref + .read(stockInRepositoryProvider) + .update(widget.editOrderId!, data); ref.read(stockInListProvider.notifier).reload(); } else { await ref.read(stockInListProvider.notifier).createOrder(data); @@ -300,6 +411,58 @@ class _StockInFormScreenState extends ConsumerState { } } + /// 是否有值得保留的录入(用于退出前判断是否提示保存草稿)。 + /// 系列/规格的默认值不算「脏」,只有用户实际录入了名称/价格/批次/生产日期/备注, + /// 或选了仓库/供应商才算。 + bool get _isDirty { + if (_remarkCtrl.text.trim().isNotEmpty) return true; + if (_warehouseId != null) return true; + if (_partnerId != null) return true; + for (final item in _items) { + if (item.selectedNameId != null) return true; + if (item.priceCtrl.text.trim().isNotEmpty) return true; + if (item.batchNoCtrl.text.trim().isNotEmpty) return true; + if (item.productionDateCtrl.text.trim().isNotEmpty) return true; + } + return false; + } + + /// 退出前处理:无录入直接离开;有未保存录入则提示「保存草稿 / 放弃 / 继续编辑」。 + Future _handleExit() async { + if (!_isDirty) { + context.go('/stock-in'); + return; + } + final choice = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('未保存的内容'), + content: + Text(_isEdit ? '当前修改尚未保存。是否保存为草稿?' : '当前入库单尚未提交。是否保存为草稿,以免内容丢失?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop('edit'), + child: const Text('继续编辑'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop('discard'), + child: const Text('放弃', style: TextStyle(color: AppTheme.danger)), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop('draft'), + child: const Text('保存草稿'), + ), + ], + ), + ); + if (choice == 'draft') { + await _submit(true); // 成功后自身会跳回列表并提示「已保存为草稿」 + } else if (choice == 'discard' && mounted) { + context.go('/stock-in'); + } + // 'edit' / null:留在当前页 + } + @override Widget build(BuildContext context) { final asyncWarehouses = ref.watch(warehouseListProvider); @@ -307,292 +470,304 @@ class _StockInFormScreenState extends ConsumerState { final currentUser = ref.watch(authStateProvider).user; final isMobile = context.isMobile; - return Scaffold( - backgroundColor: AppTheme.background, - body: Column( - children: [ - Container( - height: 52, - color: AppTheme.surface, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back, size: 20), - onPressed: () => context.go('/stock-in'), - tooltip: '返回', - ), - const SizedBox(width: 8), - Text(_isEdit ? '修改入库单' : '新建入库单', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const Spacer(), - // 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏 - if (isMobile) ...[ - ElevatedButton( - onPressed: _submitting ? null : () => _submit(false), - child: _submitting - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Text('提交'), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _handleExit(); + }, + child: Scaffold( + backgroundColor: AppTheme.background, + body: Column( + children: [ + Container( + height: 52, + color: AppTheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: _handleExit, + tooltip: '返回', ), - PopupMenuButton( - onSelected: (v) { - switch (v) { - case 'draft': - _submit(true); - break; - case 'print': - _printOrder(); - break; - case 'cancel': - context.go('/stock-in'); - break; - } - }, - itemBuilder: (_) => [ - const PopupMenuItem(value: 'draft', child: Text('保存草稿')), - if (_isEdit && _loadedOrder != null) - const PopupMenuItem(value: 'print', child: Text('打印')), - const PopupMenuItem(value: 'cancel', child: Text('取消')), + const SizedBox(width: 8), + Text(_isEdit ? '修改入库单' : '新建入库单', + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600)), + const Spacer(), + // 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏 + if (isMobile) ...[ + ElevatedButton( + onPressed: _submitting ? null : () => _submit(false), + child: _submitting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('提交'), + ), + PopupMenuButton( + onSelected: (v) { + switch (v) { + case 'draft': + _submit(true); + break; + case 'print': + _printOrder(); + break; + case 'cancel': + _handleExit(); + break; + } + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'draft', child: Text('保存草稿')), + if (_isEdit && _loadedOrder != null) + const PopupMenuItem( + value: 'print', child: Text('打印')), + const PopupMenuItem(value: 'cancel', child: Text('取消')), + ], + ), + ] else ...[ + if (_isEdit && _loadedOrder != null) ...[ + OutlinedButton.icon( + onPressed: _printOrder, + icon: const Icon(Icons.print_outlined, size: 16), + label: const Text('打印'), + ), + const SizedBox(width: 8), ], - ), - ] else ...[ - if (_isEdit && _loadedOrder != null) ...[ - OutlinedButton.icon( - onPressed: _printOrder, - icon: const Icon(Icons.print_outlined, size: 16), - label: const Text('打印'), + OutlinedButton( + onPressed: _submitting ? null : () => _submit(true), + child: const Text('保存草稿'), ), const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _submitting ? null : () => _submit(false), + icon: _submitting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.send, size: 16), + label: const Text('提交审核'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _handleExit, + icon: const Icon(Icons.cancel_outlined, size: 16), + label: const Text('取消'), + ), ], - OutlinedButton( - onPressed: _submitting ? null : () => _submit(true), - child: const Text('保存草稿'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: _submitting ? null : () => _submit(false), - icon: _submitting - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.send, size: 16), - label: const Text('提交审核'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () => context.go('/stock-in'), - icon: const Icon(Icons.cancel_outlined, size: 16), - label: const Text('取消'), - ), ], - ], + ), ), - ), - const Divider(height: 1), - if (_loadingEdit) - const Expanded(child: Center(child: CircularProgressIndicator())), - if (!_loadingEdit) - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('基本信息', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - const SizedBox(height: 16), - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - _FormField( - label: '入库仓库', - required: true, - child: asyncWarehouses.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (warehouses) => DropdownButtonFormField( - value: _warehouseId, - hint: const Text('请选择仓库', - style: TextStyle(fontSize: 13)), - items: warehouses - .map((w) => DropdownMenuItem( - value: w.id, - child: Text(w.name, - style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (v) { - setState(() => _warehouseId = v); - if (v != null) _loadInventory(v); - }, - validator: (v) => v == null ? '不能为空' : null, - decoration: const InputDecoration(), - ), - ), - ), - _FormField( - label: '供应商', - required: true, - child: asyncSuppliers.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (result) => DropdownButtonFormField( - value: _partnerId, - hint: const Text('请选择供应商', - style: TextStyle(fontSize: 13)), - items: result.data - .map((p) => DropdownMenuItem( - value: p.id, - child: Text(p.name, - style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (v) => setState(() => _partnerId = v), - validator: (v) => v == null ? '不能为空' : null, - decoration: const InputDecoration(), - ), - ), - ), - _FormField( - label: '入库员', - child: InputDecorator( - decoration: const InputDecoration(), - child: Text( - currentUser?.realName ?? '-', - style: const TextStyle(fontSize: 13), - ), - ), - ), - _FormField( - label: '入库日期', - required: true, - child: InkWell( - onTap: _pickDate, - child: InputDecorator( - decoration: const InputDecoration(), - child: Row( - children: [ - Expanded( - child: Text( - '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 13), - ), - ), - const Icon(Icons.calendar_today, - size: 16, color: AppTheme.textSecondary), - ], + const Divider(height: 1), + if (_loadingEdit) + const Expanded(child: Center(child: CircularProgressIndicator())), + if (!_loadingEdit) + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('基本信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _FormField( + label: '入库仓库', + required: true, + child: asyncWarehouses.when( + loading: () => + const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (warehouses) => + DropdownButtonFormField( + value: _warehouseId, + hint: const Text('请选择仓库', + style: TextStyle(fontSize: 13)), + items: warehouses + .map((w) => DropdownMenuItem( + value: w.id, + child: Text(w.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) { + setState(() => _warehouseId = v); + if (v != null) _loadInventory(v); + }, + validator: (v) => + v == null ? '不能为空' : null, + decoration: const InputDecoration(), ), ), ), - ), - ], - ), - const SizedBox(height: 16), - _FormField( - label: '备注', - width: double.infinity, - child: TextFormField( - controller: _remarkCtrl, - maxLines: 2, - decoration: const InputDecoration( - hintText: '选填,如有特殊说明请在此注明', - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('商品明细', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - const Spacer(), - ElevatedButton.icon( - onPressed: _addItem, - icon: const Icon(Icons.add, size: 16), - label: const Text('添加商品'), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 32)), - ), - ], - ), - const SizedBox(height: 12), - // 窄屏:逐项卡片竖排,避免 12 列表格横向溢出;宽屏保持表格 - if (isMobile) - Column( - children: List.generate( - _items.length, - (i) => Padding( - padding: - const EdgeInsets.only(bottom: 10), - child: _buildItemCard(i), - )), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildItemTableHeader(), - const Divider(height: 1), - ...List.generate(_items.length, _buildItemGroup), - ], - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Text('合计金额:', - style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w500)), - Text( - '¥${_totalAmount.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppTheme.danger), + _FormField( + label: '供应商', + required: true, + child: asyncSuppliers.when( + loading: () => + const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (result) => + DropdownButtonFormField( + value: _partnerId, + hint: const Text('请选择供应商', + style: TextStyle(fontSize: 13)), + items: result.data + .map((p) => DropdownMenuItem( + value: p.id, + child: Text(p.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) => + setState(() => _partnerId = v), + validator: (v) => + v == null ? '不能为空' : null, + decoration: const InputDecoration(), + ), + ), + ), + _FormField( + label: '入库员', + child: InputDecorator( + decoration: const InputDecoration(), + child: Text( + currentUser?.realName ?? '-', + style: const TextStyle(fontSize: 13), + ), + ), + ), + _FormField( + label: '入库日期', + required: true, + width: 320, + child: DatePickerField( + value: formatYmd(_orderDate), + onChanged: (v) { + final d = parseYmd(v); + if (d != null) + setState(() => _orderDate = d); + }, + ), ), ], ), - ), - ], + const SizedBox(height: 16), + _FormField( + label: '备注', + width: double.infinity, + child: TextFormField( + controller: _remarkCtrl, + maxLines: 2, + decoration: const InputDecoration( + hintText: '选填,如有特殊说明请在此注明', + ), + ), + ), + ], + ), ), ), - ), - ], + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('商品明细', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const Spacer(), + ElevatedButton.icon( + onPressed: _addItem, + icon: const Icon(Icons.add, size: 16), + label: const Text('添加商品'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 32)), + ), + ], + ), + const SizedBox(height: 12), + // 窄屏:逐项卡片竖排,避免 12 列表格横向溢出;宽屏保持表格 + if (isMobile) + Column( + children: List.generate( + _items.length, + (i) => Padding( + padding: const EdgeInsets.only( + bottom: 10), + child: _buildItemCard(i), + )), + ) + else + Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + _buildItemTableHeader(), + const Divider(height: 1), + ...List.generate( + _items.length, _buildItemGroup), + ], + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text('合计金额:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500)), + Text( + '¥${_totalAmount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.danger), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), ), ), ), - ), - ], + ], + ), ), ); } @@ -612,8 +787,14 @@ class _StockInFormScreenState extends ConsumerState { hint: '选择名称', dialogTitle: '选择商品名称', isRequired: true, + onCreate: (kw) async { + await ref.read(productNameListProvider.notifier).create({'name': kw}); + final list = await ref.read(productNameListProvider.future); + return list.where((o) => o.name == kw).firstOrNull?.id; + }, onChanged: (v) => setState(() { item.selectedNameId = v; + item.nameTouched = true; item.productId = null; }), ), @@ -633,8 +814,16 @@ class _StockInFormScreenState extends ConsumerState { hint: '选择系列', dialogTitle: '选择系列', isRequired: true, + onCreate: (kw) async { + await ref + .read(productSeriesListProvider.notifier) + .create({'name': kw}); + final list = await ref.read(productSeriesListProvider.future); + return list.where((o) => o.name == kw).firstOrNull?.id; + }, onChanged: (v) => setState(() { item.selectedSeriesId = v; + item.seriesTouched = true; item.productId = null; }), ), @@ -654,8 +843,14 @@ class _StockInFormScreenState extends ConsumerState { hint: '选择规格', dialogTitle: '选择规格', isRequired: true, + onCreate: (kw) async { + await ref.read(productSpecListProvider.notifier).create({'name': kw}); + final list = await ref.read(productSpecListProvider.future); + return list.where((o) => o.name == kw).firstOrNull?.id; + }, onChanged: (v) => setState(() { item.selectedSpecId = v; + item.specTouched = true; item.productId = null; }), ), @@ -713,31 +908,17 @@ class _StockInFormScreenState extends ConsumerState { } Widget _dateField(_ItemRow item) { - return TextFormField( - controller: item.productionDateCtrl, - readOnly: true, - decoration: const InputDecoration( - hintText: '请选择', - labelText: '* 生产日期', - isDense: true, - suffixIcon: Icon(Icons.calendar_today, size: 14), - ), - style: const TextStyle(fontSize: 13), - onTap: () async { - final date = await showDatePicker( - context: context, - initialDate: item.productionDate ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime(2100), - locale: const Locale('zh', 'CN'), - ); - if (date != null) { - setState(() { - item.productionDate = date; - item.productionDateCtrl.text = - '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - }); - } + return DatePickerField( + label: '生产日期', + isRequired: true, + value: item.productionDateCtrl.text.isEmpty + ? null + : item.productionDateCtrl.text, + onChanged: (v) { + setState(() { + item.productionDateCtrl.text = v ?? ''; + item.productionDate = parseYmd(v); + }); }, ); } @@ -802,9 +983,7 @@ class _StockInFormScreenState extends ConsumerState { loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), data: (docs) => SearchableOptionField( - options: docs - .map((o) => OptionItem(id: o.id, name: o.title)) - .toList(), + options: docs.map((o) => OptionItem(id: o.id, name: o.title)).toList(), selectedId: item.selectedDescriptionDocId, hint: '选择介绍文档(可选)', dialogTitle: '选择介绍文档', @@ -814,15 +993,10 @@ class _StockInFormScreenState extends ConsumerState { ); } - int _specQtyOf(_ItemRow item) => - ref.read(productSpecListProvider).valueOrNull - ?.where((o) => o.id == item.selectedSpecId) - .firstOrNull - ?.quantity ?? - 0; - String _productCodeOf(_ItemRow item) => - ref.read(productNameListProvider).valueOrNull + ref + .read(productNameListProvider) + .valueOrNull ?.where((o) => o.id == item.selectedNameId) .firstOrNull ?.code ?? @@ -842,11 +1016,10 @@ class _StockInFormScreenState extends ConsumerState { color: const Color(0xFFF0F4FF), child: Row(children: [ SizedBox(width: 36, child: th('序号')), - Expanded(flex: 20, child: th('名称')), - Expanded(flex: 13, child: th('系列')), - Expanded(flex: 13, child: th('规格')), - Expanded(flex: 12, child: th('生产日期')), - Expanded(flex: 9, child: th('单品数量')), + Expanded(flex: 18, child: th('名称')), + Expanded(flex: 12, child: th('系列')), + Expanded(flex: 12, child: th('规格')), + Expanded(flex: 24, child: th('生产日期')), Expanded(flex: 10, child: th('数量')), Expanded(flex: 10, child: th('单价')), Expanded(flex: 10, child: th('金额')), @@ -862,16 +1035,12 @@ class _StockInFormScreenState extends ConsumerState { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; final amount = qty * price; - final specQty = _specQtyOf(item); final productCode = _productCodeOf(item); Widget tc(String s, {Color? color, FontWeight? weight}) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text(s, - style: TextStyle( - fontSize: 13, - color: color, - fontWeight: weight)), + style: TextStyle(fontSize: 13, color: color, fontWeight: weight)), ); final hasOptional = item.batchNoCtrl.text.isNotEmpty || @@ -886,36 +1055,31 @@ class _StockInFormScreenState extends ConsumerState { IntrinsicHeight( child: Container( color: index.isEven ? Colors.white : const Color(0xFFFAFAFA), - child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + child: + Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: 36, child: tc('${index + 1}', color: AppTheme.textSecondary)), Expanded( - flex: 20, + flex: 18, child: Padding( padding: const EdgeInsets.all(4), child: _nameField(item))), Expanded( - flex: 13, + flex: 12, child: Padding( padding: const EdgeInsets.all(4), child: _seriesField(item))), Expanded( - flex: 13, + flex: 12, child: Padding( padding: const EdgeInsets.all(4), child: _specField(item))), Expanded( - flex: 12, + flex: 24, child: Padding( padding: const EdgeInsets.all(4), child: _dateField(item))), - Expanded( - flex: 9, - child: tc(specQty > 0 ? '$specQty' : '-', - color: specQty > 0 - ? Colors.black87 - : AppTheme.textSecondary)), Expanded( flex: 10, child: Padding( @@ -1016,13 +1180,13 @@ class _StockInFormScreenState extends ConsumerState { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; final amount = qty * price; - final specQty = _specQtyOf(item); final productCode = _productCodeOf(item); return MobileListCard( title: Text('商品 ${index + 1}'), trailing: IconButton( - icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger), + icon: + const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger), onPressed: _items.length > 1 ? () => _removeItem(index) : null, tooltip: '删除', visualDensity: VisualDensity.compact, @@ -1032,7 +1196,6 @@ class _StockInFormScreenState extends ConsumerState { MobileCardField('系列', null, valueWidget: _seriesField(item)), MobileCardField('规格', null, valueWidget: _specField(item)), MobileCardField('生产日期', null, valueWidget: _dateField(item)), - MobileCardField('单品数量', specQty > 0 ? '$specQty' : '-'), MobileCardField('数量', null, valueWidget: _qtyField(item)), MobileCardField('单价', null, valueWidget: _priceField(item)), MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'), @@ -1057,7 +1220,8 @@ class _StockInFormScreenState extends ConsumerState { if (item.expanded) MobileCardField('储存方式', null, valueWidget: _storageField(item)), if (item.expanded) - MobileCardField('介绍文档', null, valueWidget: _descriptionDocField(item)), + MobileCardField('介绍文档', null, + valueWidget: _descriptionDocField(item)), ], ); } @@ -1066,7 +1230,8 @@ class _StockInFormScreenState extends ConsumerState { if (productId == null) { return const Padding( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)), + child: Text('-', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)), ); } final qty = _inventoryMap[productId]; @@ -1081,20 +1246,10 @@ class _StockInFormScreenState extends ConsumerState { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text(text, - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)), + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w700, color: color)), ); } - - Future _pickDate() async { - final date = await showDatePicker( - context: context, - initialDate: _orderDate, - firstDate: DateTime(2020), - lastDate: DateTime(2030), - locale: const Locale('zh', 'CN'), - ); - if (date != null) setState(() => _orderDate = date); - } } class _ItemRow { @@ -1113,6 +1268,10 @@ class _ItemRow { final TextEditingController productionDateCtrl = TextEditingController(); DateTime? productionDate; bool expanded = false; + // 用户是否手动改过名称/系列/规格:改过则默认值不再覆盖 + bool nameTouched = false; + bool seriesTouched = false; + bool specTouched = false; void dispose() { qtyCtrl.dispose(); @@ -1140,8 +1299,8 @@ class _OptionalField extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text(label, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), + style: + const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), const SizedBox(height: 4), child, ], @@ -1168,9 +1327,7 @@ class _FormField extends StatelessWidget { // 窄屏(手机)字段占满整行,便于点选;宽屏沿用固定宽度配合 Wrap 多列。 final effectiveWidth = width == double.infinity ? double.infinity - : (context.isMobile - ? MediaQuery.sizeOf(context).width - 64 - : width); + : (context.isMobile ? MediaQuery.sizeOf(context).width - 64 : width); return SizedBox( width: effectiveWidth, child: Column( @@ -1179,9 +1336,11 @@ class _FormField extends StatelessWidget { Row( children: [ if (required) - const Text('*', style: TextStyle(color: AppTheme.danger, fontSize: 13)), + const Text('*', + style: TextStyle(color: AppTheme.danger, fontSize: 13)), Text(label, - style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), ], ), const SizedBox(height: 6), diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index b0220ff..da6a735 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -6,7 +6,9 @@ import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; import '../../core/auth/auth_state.dart'; import '../../core/utils/print_util.dart'; +import '../../core/utils/date_util.dart'; import '../../models/stock_out.dart'; +import '../../widgets/date_picker_field.dart'; import '../../providers/inventory_provider.dart'; import '../../providers/partner_provider.dart'; import '../../providers/stock_out_provider.dart'; @@ -92,7 +94,8 @@ class _StockOutFormScreenState extends ConsumerState { Future _loadEditOrder() async { setState(() => _loadingEdit = true); try { - final order = await ref.read(stockOutRepositoryProvider).get(widget.editOrderId!); + final order = + await ref.read(stockOutRepositoryProvider).get(widget.editOrderId!); setState(() { _loadedOrder = order; @@ -182,7 +185,8 @@ class _StockOutFormScreenState extends ConsumerState { setState(() { _inventoryPickerItems = productMap.values.toList(); _inventoryMap = { - for (final item in _inventoryPickerItems) item.productId: item.availableQty + for (final item in _inventoryPickerItems) + item.productId: item.availableQty }; }); } catch (_) {} @@ -191,7 +195,8 @@ class _StockOutFormScreenState extends ConsumerState { Future _addItem() async { if (_warehouseId == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请先选择出库仓库'), backgroundColor: AppTheme.danger), + const SnackBar( + content: Text('请先选择出库仓库'), backgroundColor: AppTheme.danger), ); return; } @@ -231,18 +236,20 @@ class _StockOutFormScreenState extends ConsumerState { if (!asDraft && !_formKey.currentState!.validate()) return; if (_warehouseId == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请选择出库仓库'), backgroundColor: AppTheme.danger), + const SnackBar( + content: Text('请选择出库仓库'), backgroundColor: AppTheme.danger), ); return; } if (_items.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger), + const SnackBar( + content: Text('请添加商品明细'), backgroundColor: AppTheme.danger), ); return; } - final invalidQtyIndex = _items.indexWhere( - (item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0); + final invalidQtyIndex = _items + .indexWhere((item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0); if (invalidQtyIndex >= 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -269,8 +276,7 @@ class _StockOutFormScreenState extends ConsumerState { final data = { 'warehouse_id': _warehouseId, if (_partnerId != null) 'partner_id': _partnerId, - 'order_date': - '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', + 'order_date': formatYmd(_orderDate), if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(), 'items': itemsData, 'status': asDraft ? 'draft' : 'pending', @@ -278,7 +284,9 @@ class _StockOutFormScreenState extends ConsumerState { try { if (_isEdit) { - await ref.read(stockOutRepositoryProvider).update(widget.editOrderId!, data); + await ref + .read(stockOutRepositoryProvider) + .update(widget.editOrderId!, data); ref.read(stockOutListProvider.notifier).reload(); } else { await ref.read(stockOutListProvider.notifier).createOrder(data); @@ -303,6 +311,52 @@ class _StockOutFormScreenState extends ConsumerState { } } + /// 是否有值得保留的录入(用于退出前判断是否提示保存草稿)。 + bool get _isDirty { + if (_remarkCtrl.text.trim().isNotEmpty) return true; + if (_warehouseId != null) return true; + if (_partnerId != null) return true; + for (final item in _items) { + if (item.productId != null) return true; + } + return false; + } + + /// 退出前处理:无录入直接离开;有未保存录入则提示「保存草稿 / 放弃 / 继续编辑」。 + Future _handleExit() async { + if (!_isDirty) { + context.go('/stock-out'); + return; + } + final choice = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('未保存的内容'), + content: + Text(_isEdit ? '当前修改尚未保存。是否保存为草稿?' : '当前出库单尚未提交。是否保存为草稿,以免内容丢失?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop('edit'), + child: const Text('继续编辑'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop('discard'), + child: const Text('放弃', style: TextStyle(color: AppTheme.danger)), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop('draft'), + child: const Text('保存草稿'), + ), + ], + ), + ); + if (choice == 'draft') { + await _submit(true); + } else if (choice == 'discard' && mounted) { + context.go('/stock-out'); + } + } + @override Widget build(BuildContext context) { final asyncWarehouses = ref.watch(warehouseListProvider); @@ -310,320 +364,346 @@ class _StockOutFormScreenState extends ConsumerState { final currentUser = ref.watch(authStateProvider).user; final isMobile = context.isMobile; - return Scaffold( - backgroundColor: AppTheme.background, - body: Column( - children: [ - Container( - height: 52, - color: AppTheme.surface, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back, size: 20), - onPressed: () => context.go('/stock-out'), - tooltip: '返回', - ), - const SizedBox(width: 8), - Text(_isEdit ? '修改出库单' : '新建出库单', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const Spacer(), - // 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏 - if (isMobile) ...[ - ElevatedButton( - onPressed: _submitting ? null : () => _submit(false), - child: _submitting - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Text('提交'), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _handleExit(); + }, + child: Scaffold( + backgroundColor: AppTheme.background, + body: Column( + children: [ + Container( + height: 52, + color: AppTheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: _handleExit, + tooltip: '返回', ), - PopupMenuButton( - onSelected: (v) { - switch (v) { - case 'draft': - _submit(true); - break; - case 'print': - _printOrder(); - break; - case 'cancel': - context.go('/stock-out'); - break; - } - }, - itemBuilder: (_) => [ - const PopupMenuItem(value: 'draft', child: Text('保存草稿')), - if (_isEdit && _loadedOrder != null) - const PopupMenuItem(value: 'print', child: Text('打印')), - const PopupMenuItem(value: 'cancel', child: Text('取消')), + const SizedBox(width: 8), + Text(_isEdit ? '修改出库单' : '新建出库单', + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600)), + const Spacer(), + // 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏 + if (isMobile) ...[ + ElevatedButton( + onPressed: _submitting ? null : () => _submit(false), + child: _submitting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('提交'), + ), + PopupMenuButton( + onSelected: (v) { + switch (v) { + case 'draft': + _submit(true); + break; + case 'print': + _printOrder(); + break; + case 'cancel': + _handleExit(); + break; + } + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'draft', child: Text('保存草稿')), + if (_isEdit && _loadedOrder != null) + const PopupMenuItem( + value: 'print', child: Text('打印')), + const PopupMenuItem(value: 'cancel', child: Text('取消')), + ], + ), + ] else ...[ + if (_isEdit && _loadedOrder != null) ...[ + OutlinedButton.icon( + onPressed: _printOrder, + icon: const Icon(Icons.print_outlined, size: 16), + label: const Text('打印'), + ), + const SizedBox(width: 8), ], - ), - ] else ...[ - if (_isEdit && _loadedOrder != null) ...[ - OutlinedButton.icon( - onPressed: _printOrder, - icon: const Icon(Icons.print_outlined, size: 16), - label: const Text('打印'), + OutlinedButton( + onPressed: _submitting ? null : () => _submit(true), + child: const Text('保存草稿'), ), const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _submitting ? null : () => _submit(false), + icon: _submitting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.send, size: 16), + label: const Text('提交审核'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _handleExit, + icon: const Icon(Icons.cancel_outlined, size: 16), + label: const Text('取消'), + ), ], - OutlinedButton( - onPressed: _submitting ? null : () => _submit(true), - child: const Text('保存草稿'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: _submitting ? null : () => _submit(false), - icon: _submitting - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.send, size: 16), - label: const Text('提交审核'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () => context.go('/stock-out'), - icon: const Icon(Icons.cancel_outlined, size: 16), - label: const Text('取消'), - ), ], - ], + ), ), - ), - const Divider(height: 1), - if (_loadingEdit) - const Expanded(child: Center(child: CircularProgressIndicator())), - if (!_loadingEdit) - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('基本信息', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - const SizedBox(height: 16), - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - _FormField( - label: '出库仓库', - required: true, - child: asyncWarehouses.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (warehouses) => DropdownButtonFormField( - value: _warehouseId, - hint: const Text('请选择仓库', - style: TextStyle(fontSize: 13)), - items: warehouses - .map((w) => DropdownMenuItem( - value: w.id, - child: Text(w.name, - style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (v) { - setState(() => _warehouseId = v); - if (v != null) _loadInventory(v); - }, - validator: (v) => v == null ? '不能为空' : null, - decoration: const InputDecoration(), - ), - ), - ), - _FormField( - label: '客户', - child: asyncCustomers.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (result) => DropdownButtonFormField( - value: _partnerId, - hint: const Text('请选择客户', - style: TextStyle(fontSize: 13)), - items: result.data - .map((p) => DropdownMenuItem( - value: p.id, - child: Text(p.name, - style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (v) => setState(() => _partnerId = v), - decoration: const InputDecoration(), - ), - ), - ), - _FormField( - label: '出库员', - child: InputDecorator( - decoration: const InputDecoration(), - child: Text( - currentUser?.realName ?? '-', - style: const TextStyle(fontSize: 13), - ), - ), - ), - _FormField( - label: '出库日期', - required: true, - child: InkWell( - onTap: _pickDate, - child: InputDecorator( - decoration: const InputDecoration(), - child: Row( - children: [ - Expanded( - child: Text( - '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 13), - ), - ), - const Icon(Icons.calendar_today, - size: 16, color: AppTheme.textSecondary), - ], + const Divider(height: 1), + if (_loadingEdit) + const Expanded(child: Center(child: CircularProgressIndicator())), + if (!_loadingEdit) + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('基本信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _FormField( + label: '出库仓库', + required: true, + child: asyncWarehouses.when( + loading: () => + const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (warehouses) => + DropdownButtonFormField( + value: _warehouseId, + hint: const Text('请选择仓库', + style: TextStyle(fontSize: 13)), + items: warehouses + .map((w) => DropdownMenuItem( + value: w.id, + child: Text(w.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) { + setState(() => _warehouseId = v); + if (v != null) _loadInventory(v); + }, + validator: (v) => + v == null ? '不能为空' : null, + decoration: const InputDecoration(), ), ), ), - ), - ], - ), - const SizedBox(height: 16), - _FormField( - label: '备注', - width: double.infinity, - child: TextFormField( - controller: _remarkCtrl, - maxLines: 2, - decoration: const InputDecoration( - hintText: '选填,如有特殊说明请在此注明', - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('商品明细', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - const Spacer(), - ElevatedButton.icon( - onPressed: _addItem, - icon: const Icon(Icons.add, size: 16), - label: const Text('添加商品'), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 32)), - ), - ], - ), - const SizedBox(height: 12), - // 窄屏:逐项卡片竖排,避免 9 列表格横向溢出;宽屏保持表格 - if (isMobile) - Column( - children: List.generate( - _items.length, - (i) => Padding( - padding: - const EdgeInsets.only(bottom: 10), - child: _buildItemCard(i), - )), - ) - else - Table( - columnWidths: const { - 0: FixedColumnWidth(36), // 序号 - 1: FlexColumnWidth(1.2), // 商品编码 - 2: FlexColumnWidth(2.0), // 商品名称 - 3: FlexColumnWidth(1.2), // 系列 - 4: FlexColumnWidth(1.2), // 规格 - 5: FlexColumnWidth(1.0), // 单价 - 6: FlexColumnWidth(0.8), // 数量 - 7: FlexColumnWidth(1.0), // 金额 - 8: FixedColumnWidth(48), // 操作 - }, - children: [ - TableRow( - decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), - children: [ - '序号', '商品编码', '商品名称', '系列', '规格', '单价', '数量', '金额', '操作', - ] - .map((h) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 10), - child: Text(h, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - )) - .toList(), + _FormField( + label: '客户', + child: asyncCustomers.when( + loading: () => + const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (result) => + DropdownButtonFormField( + value: _partnerId, + hint: const Text('请选择客户', + style: TextStyle(fontSize: 13)), + items: result.data + .map((p) => DropdownMenuItem( + value: p.id, + child: Text(p.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) => + setState(() => _partnerId = v), + decoration: const InputDecoration(), + ), + ), + ), + _FormField( + label: '出库员', + child: InputDecorator( + decoration: const InputDecoration(), + child: Text( + currentUser?.realName ?? '-', + style: const TextStyle(fontSize: 13), + ), + ), + ), + _FormField( + label: '出库日期', + required: true, + width: 320, + child: DatePickerField( + value: formatYmd(_orderDate), + onChanged: (v) { + final d = parseYmd(v); + if (d != null) + setState(() => _orderDate = d); + }, + ), ), - ...List.generate(_items.length, (i) => _buildItemRow(i)), ], ), - if (_items.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: Text('暂无商品,点击"添加商品"从库存中选择', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 13))), + const SizedBox(height: 16), + _FormField( + label: '备注', + width: double.infinity, + child: TextFormField( + controller: _remarkCtrl, + maxLines: 2, + decoration: const InputDecoration( + hintText: '选填,如有特殊说明请在此注明', + ), + ), ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - const Text('合计金额:', + const Text('商品明细', style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w500)), - Text( - '¥${_totalAmount.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppTheme.danger), + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const Spacer(), + ElevatedButton.icon( + onPressed: _addItem, + icon: const Icon(Icons.add, size: 16), + label: const Text('添加商品'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 32)), ), ], ), - ), - ], + const SizedBox(height: 12), + // 窄屏:逐项卡片竖排,避免 9 列表格横向溢出;宽屏保持表格 + if (isMobile) + Column( + children: List.generate( + _items.length, + (i) => Padding( + padding: const EdgeInsets.only( + bottom: 10), + child: _buildItemCard(i), + )), + ) + else + Table( + columnWidths: const { + 0: FixedColumnWidth(36), // 序号 + 1: FlexColumnWidth(1.2), // 商品编码 + 2: FlexColumnWidth(2.0), // 商品名称 + 3: FlexColumnWidth(1.2), // 系列 + 4: FlexColumnWidth(1.2), // 规格 + 5: FlexColumnWidth(1.0), // 单价 + 6: FlexColumnWidth(0.8), // 数量 + 7: FlexColumnWidth(1.0), // 金额 + 8: FixedColumnWidth(48), // 操作 + }, + children: [ + TableRow( + decoration: const BoxDecoration( + color: Color(0xFFF0F4FF)), + children: [ + '序号', + '商品编码', + '商品名称', + '系列', + '规格', + '单价', + '数量', + '金额', + '操作', + ] + .map((h) => Padding( + padding: const EdgeInsets + .symmetric( + horizontal: 8, + vertical: 10), + child: Text(h, + style: const TextStyle( + fontSize: 13, + fontWeight: + FontWeight.w600, + color: AppTheme + .primaryDark)), + )) + .toList(), + ), + ...List.generate(_items.length, + (i) => _buildItemRow(i)), + ], + ), + if (_items.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text('暂无商品,点击"添加商品"从库存中选择', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 13))), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text('合计金额:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500)), + Text( + '¥${_totalAmount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.danger), + ), + ], + ), + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ), - ), - ], + ], + ), ), ); } @@ -640,26 +720,39 @@ class _StockOutFormScreenState extends ConsumerState { ), children: [ // 序号 - _cell(Text('${index + 1}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary))), + _cell(Text('${index + 1}', + style: + const TextStyle(fontSize: 13, color: AppTheme.textSecondary))), // 商品编码 - _cell(Text(item.productCode, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), + _cell(Text(item.productCode, + style: + const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), // 商品名称 - _cell(Text(item.productName, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)), + _cell(Text(item.productName, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis)), // 系列 - _cell(Text(item.series, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), + _cell(Text(item.series, + style: + const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), // 规格 - _cell(Text(item.spec, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), + _cell(Text(item.spec, + style: + const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), // 单价 - _cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-', style: const TextStyle(fontSize: 13))), + _cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-', + style: const TextStyle(fontSize: 13))), // 数量 Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)), // 金额 - _cell(Text('¥${amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))), + _cell(Text('¥${amount.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))), // 操作 Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: IconButton( - icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger), + icon: const Icon(Icons.delete_outline, + size: 18, color: AppTheme.danger), onPressed: () => _removeItem(index), tooltip: '删除', padding: EdgeInsets.zero, @@ -676,7 +769,9 @@ class _StockOutFormScreenState extends ConsumerState { decoration: const InputDecoration(hintText: '0', isDense: true), style: const TextStyle(fontSize: 13), keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) + ], onChanged: (_) => setState(() {}), validator: (v) { if (v == null || v.isEmpty) return '不能为空'; @@ -695,9 +790,11 @@ class _StockOutFormScreenState extends ConsumerState { return MobileListCard( title: Text(item.productName), - subtitle: item.productCode.isNotEmpty ? Text('编码 ${item.productCode}') : null, + subtitle: + item.productCode.isNotEmpty ? Text('编码 ${item.productCode}') : null, trailing: IconButton( - icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger), + icon: + const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger), onPressed: () => _removeItem(index), tooltip: '删除', visualDensity: VisualDensity.compact, @@ -719,7 +816,8 @@ class _StockOutFormScreenState extends ConsumerState { Widget _buildInventoryCell(int? productId, [double? available]) { if (productId == null) { - return _cell(const Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13))); + return _cell(const Text('-', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 13))); } final qty = available ?? _inventoryMap[productId]; final text = qty != null @@ -730,18 +828,9 @@ class _StockOutFormScreenState extends ConsumerState { : qty <= 0 ? AppTheme.danger : AppTheme.primary; - return _cell(Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color))); - } - - Future _pickDate() async { - final date = await showDatePicker( - context: context, - initialDate: _orderDate, - firstDate: DateTime(2020), - lastDate: DateTime(2030), - locale: const Locale('zh', 'CN'), - ); - if (date != null) setState(() => _orderDate = date); + return _cell(Text(text, + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w700, color: color))); } } @@ -763,9 +852,7 @@ class _FormField extends StatelessWidget { // 窄屏(手机)字段占满整行,便于点选;宽屏沿用固定宽度配合 Wrap 多列。 final effectiveWidth = width == double.infinity ? double.infinity - : (context.isMobile - ? MediaQuery.sizeOf(context).width - 64 - : width); + : (context.isMobile ? MediaQuery.sizeOf(context).width - 64 : width); return SizedBox( width: effectiveWidth, child: Column( @@ -774,9 +861,11 @@ class _FormField extends StatelessWidget { Row( children: [ if (required) - const Text('*', style: TextStyle(color: AppTheme.danger, fontSize: 13)), + const Text('*', + style: TextStyle(color: AppTheme.danger, fontSize: 13)), Text(label, - style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), ], ), const SizedBox(height: 6), @@ -820,7 +909,8 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { @override Widget build(BuildContext context) { final filtered = _filtered; - final allSelected = filtered.isNotEmpty && filtered.every((e) => _selected.contains(e.productId)); + final allSelected = filtered.isNotEmpty && + filtered.every((e) => _selected.contains(e.productId)); return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), @@ -835,16 +925,19 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { child: Row( children: [ const Text('选择商品', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + style: + TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(width: 8), Text('已选 ${_selected.length} 个', - style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), const Spacer(), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + constraints: + const BoxConstraints(minWidth: 32, minHeight: 32), ), ], ), @@ -859,7 +952,8 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { hintText: '搜索商品编码、名称或系列', prefixIcon: Icon(Icons.search, size: 18), isDense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), + contentPadding: + EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), onChanged: (v) => setState(() => _search = v), ), @@ -978,7 +1072,8 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { ? null : () { final result = widget.items - .where((item) => _selected.contains(item.productId)) + .where((item) => + _selected.contains(item.productId)) .toList(); Navigator.pop(context, result); }, @@ -1002,7 +1097,8 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { color: AppTheme.primaryDark)), ); - Widget _dataCell(String text, double width, {Color? color, bool bold = false}) => + Widget _dataCell(String text, double width, + {Color? color, bool bold = false}) => SizedBox( width: width, child: Text( diff --git a/client/lib/widgets/date_picker_field.dart b/client/lib/widgets/date_picker_field.dart new file mode 100644 index 0000000..b0f8ebb --- /dev/null +++ b/client/lib/widgets/date_picker_field.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import '../core/theme/app_theme.dart'; +import '../core/utils/date_util.dart'; + +/// 年/月/日 三个可编辑下拉框的日期选择组件,替代原生 showDatePicker。 +/// +/// - 每个下拉是 Material 3 `DropdownMenu`:点开可选,键入数字即过滤定位 +/// (年/月/日均为有界域,条目覆盖全范围,等价于「手动输入数字」)。 +/// - 改月/年时把超出当月的「日」自动 clamp 回当月最大值。 +/// - 值以 `yyyy-MM-dd` 字符串进出([value] / [onChanged]);三者未填齐时回调 null。 +class DatePickerField extends StatefulWidget { + final String? value; // yyyy-MM-dd + final ValueChanged onChanged; + final bool isRequired; + final String? label; + + const DatePickerField({ + super.key, + this.value, + required this.onChanged, + this.isRequired = false, + this.label, + }); + + @override + State createState() => _DatePickerFieldState(); +} + +class _DatePickerFieldState extends State { + int? _year; + int? _month; + int? _day; + + @override + void initState() { + super.initState(); + _parse(widget.value); + } + + @override + void didUpdateWidget(DatePickerField old) { + super.didUpdateWidget(old); + if (old.value != widget.value) { + _parse(widget.value); + } + } + + void _parse(String? v) { + final d = parseYmd(v); + _year = d?.year; + _month = d?.month; + _day = d?.day; + } + + int get _daysInMonth { + final y = _year ?? DateTime.now().year; + final m = _month ?? 1; + return DateTime(y, m + 1, 0).day; // 下月第 0 天 = 当月最后一天 + } + + String? get _composed => composeYmd(_year, _month, _day); + + void _emit(FormFieldState field) { + // 改月/年后把超界的日 clamp 回当月最大值(与 composeYmd 一致,State 同步显示) + if (_day != null && _day! > _daysInMonth) { + _day = _daysInMonth; + } + final v = _composed; + field.didChange(v); + widget.onChanged(v); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final years = [for (var y = now.year - 20; y <= now.year + 5; y++) y]; + final months = [for (var m = 1; m <= 12; m++) m]; + final days = [for (var d = 1; d <= _daysInMonth; d++) d]; + + // 每个下拉用 Expanded 撑满分得的宽度(expandedInsets:zero 让 DropdownMenu 填满父级), + // trailingIcon 用紧凑小箭头,避免默认大图标按钮挤掉数字(曾导致「2026」被裁成「007」)。 + Widget menu({ + required String label, + required int? value, + required List items, + required ValueChanged onSel, + required FormFieldState field, + }) { + return DropdownMenu( + initialSelection: value, + label: Text(label, style: const TextStyle(fontSize: 11)), + enableFilter: true, + requestFocusOnTap: true, + textStyle: const TextStyle(fontSize: 13), + menuHeight: 280, + expandedInsets: EdgeInsets.zero, + trailingIcon: const Icon(Icons.arrow_drop_down, size: 18), + selectedTrailingIcon: const Icon(Icons.arrow_drop_up, size: 18), + inputDecorationTheme: const InputDecorationTheme( + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 6, vertical: 8), + ), + dropdownMenuEntries: [ + for (final i in items) DropdownMenuEntry(value: i, label: '$i'), + ], + onSelected: onSel, + ); + } + + return FormField( + initialValue: widget.value, + validator: widget.isRequired + ? (v) => (_composed == null ? '请选择日期' : null) + : null, + builder: (field) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + flex: 5, + child: menu( + label: '年', + value: _year, + items: years, + field: field, + onSel: (v) { + _year = v; + _emit(field); + }, + ), + ), + const SizedBox(width: 6), + Expanded( + flex: 4, + child: menu( + label: '月', + value: _month, + items: months, + field: field, + onSel: (v) { + _month = v; + _emit(field); + }, + ), + ), + const SizedBox(width: 6), + Expanded( + flex: 4, + child: menu( + label: '日', + value: _day, + items: days, + field: field, + onSel: (v) { + _day = v; + _emit(field); + }, + ), + ), + ], + ), + if (field.errorText != null) + Padding( + padding: const EdgeInsets.only(left: 12, top: 4), + child: Text( + field.errorText!, + style: const TextStyle(fontSize: 11, color: AppTheme.danger), + ), + ), + ], + ); + }, + ); + } +} diff --git a/client/lib/widgets/searchable_option_field.dart b/client/lib/widgets/searchable_option_field.dart index 177d07b..e221cfb 100644 --- a/client/lib/widgets/searchable_option_field.dart +++ b/client/lib/widgets/searchable_option_field.dart @@ -13,8 +13,9 @@ class OptionItem { late final String _initials; OptionItem({required this.id, required this.name, this.code}) { - _fullPinyin = PinyinHelper.getPinyinE(name, separator: '', defPinyin: '').toLowerCase(); - _initials = PinyinHelper.getShortPinyin(name).toLowerCase(); + _fullPinyin = PinyinHelper.getPinyinE(name, separator: '', defPinyin: '') + .toLowerCase(); + _initials = PinyinHelper.getShortPinyin(name).toLowerCase(); } bool matches(String kw) { @@ -36,6 +37,10 @@ class SearchableOptionField extends StatelessWidget { final bool isRequired; final bool isDense; + /// 可选:搜索无匹配时「新增到基础数据」。回调收到当前关键字(用户可在确认框里改), + /// 创建成功返回新选项 id(自动选中),失败/取消返回 null。为空则不显示新增入口。 + final Future Function(String keyword)? onCreate; + const SearchableOptionField({ super.key, required this.options, @@ -45,6 +50,7 @@ class SearchableOptionField extends StatelessWidget { required this.onChanged, this.isRequired = false, this.isDense = true, + this.onCreate, }); String get _displayText { @@ -59,6 +65,7 @@ class SearchableOptionField extends StatelessWidget { title: dialogTitle, options: options, selectedId: selectedId, + onCreate: onCreate, ), ); // result == -1 means "clear selection" @@ -86,9 +93,11 @@ class SearchableOptionField extends StatelessWidget { suffixIcon: selected ? GestureDetector( onTap: () => onChanged(null), - child: const Icon(Icons.close, size: 14, color: AppTheme.textSecondary), + child: const Icon(Icons.close, + size: 14, color: AppTheme.textSecondary), ) - : const Icon(Icons.arrow_drop_down, size: 16, color: AppTheme.textSecondary), + : const Icon(Icons.arrow_drop_down, + size: 16, color: AppTheme.textSecondary), ), isEmpty: !selected, child: Text( @@ -110,7 +119,13 @@ class _SearchDialog extends StatefulWidget { final String title; final List options; final int? selectedId; - const _SearchDialog({required this.title, required this.options, this.selectedId}); + final Future Function(String keyword)? onCreate; + const _SearchDialog({ + required this.title, + required this.options, + this.selectedId, + this.onCreate, + }); @override State<_SearchDialog> createState() => _SearchDialogState(); @@ -119,6 +134,7 @@ class _SearchDialog extends StatefulWidget { class _SearchDialogState extends State<_SearchDialog> { final _ctrl = TextEditingController(); String _keyword = ''; + bool _creating = false; @override void dispose() { @@ -126,11 +142,68 @@ class _SearchDialogState extends State<_SearchDialog> { super.dispose(); } + /// 「新增到基础数据」流程:确认(名称可改)→ onCreate → 成功则带新 id 关闭搜索框。 + Future _createFlow() async { + final kw = _keyword.trim(); + if (widget.onCreate == null || kw.isEmpty) return; + final nameCtrl = TextEditingController(text: kw); + final confirmed = await showAppDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('新增到基础数据', style: TextStyle(fontSize: 16)), + content: SizedBox( + width: context.dialogWidth(320), + child: TextField( + controller: nameCtrl, + autofocus: true, + decoration: const InputDecoration(labelText: '名称', isDense: true), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('确认新增'), + ), + ], + ), + ); + if (confirmed != true) return; + final name = nameCtrl.text.trim(); + if (name.isEmpty) return; + setState(() => _creating = true); + try { + final newId = await widget.onCreate!(name); + if (!mounted) return; + if (newId != null) { + Navigator.of(context).pop(newId); // 回填并自动选中 + } else { + setState(() => _creating = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('新增失败,请重试')), + ); + } + } catch (e) { + if (!mounted) return; + setState(() => _creating = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('新增失败:$e')), + ); + } + } + @override Widget build(BuildContext context) { final filtered = _keyword.isEmpty ? widget.options : widget.options.where((o) => o.matches(_keyword)).toList(); + final kw = _keyword.trim(); + final hasExact = + widget.options.any((o) => o.name.toLowerCase() == kw.toLowerCase()); + final canCreate = widget.onCreate != null && kw.isNotEmpty && !hasExact; return AlertDialog( title: Text(widget.title, style: const TextStyle(fontSize: 16)), @@ -153,8 +226,12 @@ class _SearchDialogState extends State<_SearchDialog> { const SizedBox(height: 8), Expanded( child: filtered.isEmpty - ? const Center( - child: Text('无匹配结果', style: TextStyle(color: AppTheme.textSecondary))) + ? Center( + child: Text( + canCreate ? '无匹配结果,可新增到基础数据' : '无匹配结果', + style: const TextStyle(color: AppTheme.textSecondary), + ), + ) : ListView.builder( itemCount: filtered.length, itemBuilder: (_, i) { @@ -162,17 +239,36 @@ class _SearchDialogState extends State<_SearchDialog> { final isSelected = opt.id == widget.selectedId; return ListTile( dense: true, - title: Text(opt.name, style: const TextStyle(fontSize: 13)), + title: Text(opt.name, + style: const TextStyle(fontSize: 13)), subtitle: opt.code != null && opt.code!.isNotEmpty - ? Text(opt.code!, style: const TextStyle(fontSize: 11)) + ? Text(opt.code!, + style: const TextStyle(fontSize: 11)) : null, selected: isSelected, - selectedTileColor: AppTheme.primary.withValues(alpha: 0.08), + selectedTileColor: + AppTheme.primary.withValues(alpha: 0.08), onTap: () => Navigator.of(context).pop(opt.id), ); }, ), ), + if (canCreate) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _creating ? null : _createFlow, + icon: _creating + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add, size: 16), + label: Text('新增「$kw」到基础数据', + style: const TextStyle(fontSize: 12)), + ), + ), ], ), ), @@ -180,7 +276,8 @@ class _SearchDialogState extends State<_SearchDialog> { if (widget.selectedId != null) TextButton( onPressed: () => Navigator.of(context).pop(-1), - child: const Text('清除选择', style: TextStyle(color: AppTheme.textSecondary)), + child: const Text('清除选择', + style: TextStyle(color: AppTheme.textSecondary)), ), TextButton( onPressed: () => Navigator.of(context).pop(null), diff --git a/client/test/date_picker_field_test.dart b/client/test/date_picker_field_test.dart new file mode 100644 index 0000000..691f4b4 --- /dev/null +++ b/client/test/date_picker_field_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/utils/date_util.dart'; +import 'package:jiu_client/widgets/date_picker_field.dart'; + +void main() { + group('date_util', () { + test('formatYmd 补零', () { + expect(formatYmd(DateTime(2026, 6, 9)), '2026-06-09'); + expect(formatYmd(DateTime(2026, 12, 31)), '2026-12-31'); + }); + test('parseYmd 解析/容错', () { + expect(parseYmd('2026-06-19'), DateTime(2026, 6, 19)); + expect(parseYmd('2026-06-19T08:00:00'), DateTime(2026, 6, 19)); + expect(parseYmd(null), isNull); + expect(parseYmd(''), isNull); + }); + test('composeYmd 正常组合', () { + expect(composeYmd(2026, 6, 15), '2026-06-15'); + }); + test('composeYmd 把超界的日 clamp 回当月最大值', () { + expect(composeYmd(2026, 2, 31), '2026-02-28'); // 平年 2 月 + expect(composeYmd(2024, 2, 31), '2024-02-29'); // 闰年 2 月 + expect(composeYmd(2026, 4, 31), '2026-04-30'); // 4 月 30 天 + }); + test('composeYmd 任一为空返回 null', () { + expect(composeYmd(null, 6, 15), isNull); + expect(composeYmd(2026, null, 15), isNull); + expect(composeYmd(2026, 6, null), isNull); + }); + }); + + group('DatePickerField', () { + testWidgets('渲染三个年/月/日下拉框', (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: DatePickerField(value: '2026-06-19', onChanged: (_) {}), + ), + )); + await tester.pumpAndSettle(); + expect(find.byType(DropdownMenu), findsNWidgets(3)); + }); + + testWidgets('初始值回显到三个下拉框', (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: DatePickerField(value: '2026-06-19', onChanged: (_) {}), + ), + )); + await tester.pumpAndSettle(); + expect(find.widgetWithText(DropdownMenu, '2026'), findsOneWidget); + expect(find.widgetWithText(DropdownMenu, '6'), findsOneWidget); + expect(find.widgetWithText(DropdownMenu, '19'), findsOneWidget); + }); + }); +} diff --git a/client/test/searchable_option_create_test.dart b/client/test/searchable_option_create_test.dart new file mode 100644 index 0000000..80ac8a8 --- /dev/null +++ b/client/test/searchable_option_create_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/widgets/searchable_option_field.dart'; + +void main() { + Widget host({ + required ValueChanged onChanged, + Future Function(String)? onCreate, + }) { + return MaterialApp( + home: Scaffold( + body: SearchableOptionField( + options: [OptionItem(id: 1, name: '53度')], + selectedId: null, + hint: '选择系列', + dialogTitle: '选择系列', + onChanged: onChanged, + onCreate: onCreate, + ), + ), + ); + } + + testWidgets('搜不到时显示「新增到基础数据」入口', (tester) async { + await tester.pumpWidget(host(onChanged: (_) {}, onCreate: (_) async => 99)); + // 打开搜索对话框 + await tester.tap(find.byType(SearchableOptionField)); + await tester.pumpAndSettle(); + // 输入不存在的关键字 + await tester.enterText(find.byType(TextField).first, '52度'); + await tester.pumpAndSettle(); + expect(find.textContaining('新增「52度」到基础数据'), findsOneWidget); + }); + + testWidgets('确认新增后回填选中新 id', (tester) async { + int? selected; + await tester.pumpWidget( + host(onChanged: (v) => selected = v, onCreate: (_) async => 99)); + await tester.tap(find.byType(SearchableOptionField)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, '52度'); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('新增「52度」到基础数据')); + await tester.pumpAndSettle(); + // 确认框 + await tester.tap(find.text('确认新增')); + await tester.pumpAndSettle(); + expect(selected, 99); + }); + + testWidgets('未提供 onCreate 时不显示新增入口', (tester) async { + await tester.pumpWidget(host(onChanged: (_) {})); + await tester.tap(find.byType(SearchableOptionField)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, '52度'); + await tester.pumpAndSettle(); + expect(find.textContaining('新增'), findsNothing); + }); +}