diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index d535232..792a60b 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.67] - 2026-06-21 + +### 改进 +- 选择器改为服务端全量搜索:选供应商/客户时按关键词查全部往来单位(不再只在已加载的首页里找);出库「选择商品」弹窗的搜索也改为实时查全部库存,输入即按编码/名称/系列匹配,不再漏掉未加载的商品 + ## [1.0.66] - 2026-06-21 ### 改进 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 e9491ff..700b507 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -604,6 +604,20 @@ class _StockInFormScreenState extends ConsumerState { hint: '请选择供应商', dialogTitle: '选择供应商', isRequired: true, + onSearch: (kw) async { + final res = await ref + .read(partnerRepositoryProvider) + .list( + type: 'supplier', + keyword: kw, + pageSize: 50); + return res.data + .map((p) => OptionItem( + id: p.id, + name: p.name, + code: p.code)) + .toList(); + }, onChanged: (v) => setState(() => _partnerId = v), ), 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 e59d4b8..70c5478 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,6 +9,7 @@ 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/inventory.dart'; import '../../models/stock_out.dart'; import '../../widgets/date_picker_field.dart'; import '../../widgets/searchable_option_field.dart'; @@ -39,6 +42,39 @@ class _PickerItem { }); } +/// 把库存行按 product 聚合为选择器条目(同一 product 的多行数量相加)。 +List<_PickerItem> _aggregatePickerItems(List rows) { + final Map map = {}; + for (final inv in rows.where((inv) => inv.productId != null)) { + final pid = inv.productId!; + final existing = map[pid]; + if (existing != null) { + map[pid] = _PickerItem( + productId: pid, + productCode: existing.productCode, + productName: existing.productName, + series: existing.series, + spec: existing.spec, + unit: existing.unit, + unitPrice: existing.unitPrice ?? inv.unitPrice, + availableQty: existing.availableQty + inv.quantity, + ); + } else { + map[pid] = _PickerItem( + productId: pid, + productCode: inv.productCode, + productName: inv.productName, + series: inv.series, + spec: inv.spec, + unit: inv.unit, + unitPrice: inv.unitPrice, + availableQty: inv.quantity, + ); + } + } + return map.values.toList(); +} + class _ItemRow { int? productId; final String productCode; @@ -158,36 +194,8 @@ class _StockOutFormScreenState extends ConsumerState { .listInventory( warehouseId: warehouseId, pageSize: AppConstants.stockOutPickerPageSize); - final Map productMap = {}; - for (final inv in result.data.where((inv) => inv.productId != null)) { - final pid = inv.productId!; - if (productMap.containsKey(pid)) { - final existing = productMap[pid]!; - productMap[pid] = _PickerItem( - productId: pid, - productCode: existing.productCode, - productName: existing.productName, - series: existing.series, - spec: existing.spec, - unit: existing.unit, - unitPrice: existing.unitPrice ?? inv.unitPrice, - availableQty: existing.availableQty + inv.quantity, - ); - } else { - productMap[pid] = _PickerItem( - productId: pid, - productCode: inv.productCode, - productName: inv.productName, - series: inv.series, - spec: inv.spec, - unit: inv.unit, - unitPrice: inv.unitPrice, - availableQty: inv.quantity, - ); - } - } setState(() { - _inventoryPickerItems = productMap.values.toList(); + _inventoryPickerItems = _aggregatePickerItems(result.data); _inventoryMap = { for (final item in _inventoryPickerItems) item.productId: item.availableQty @@ -206,7 +214,10 @@ class _StockOutFormScreenState extends ConsumerState { } final selected = await showDialog>( context: context, - builder: (_) => _InventoryPickerDialog(items: _inventoryPickerItems), + builder: (_) => _InventoryPickerDialog( + items: _inventoryPickerItems, + warehouseId: _warehouseId, + ), ); if (selected == null || selected.isEmpty) return; setState(() { @@ -532,6 +543,20 @@ class _StockOutFormScreenState extends ConsumerState { selectedId: _partnerId, hint: '请选择客户', dialogTitle: '选择客户', + onSearch: (kw) async { + final res = await ref + .read(partnerRepositoryProvider) + .list( + type: 'customer', + keyword: kw, + pageSize: 50); + return res.data + .map((p) => OptionItem( + id: p.id, + name: p.name, + code: p.code)) + .toList(); + }, onChanged: (v) => setState(() => _partnerId = v), ), @@ -873,39 +898,67 @@ class _FormField extends StatelessWidget { } } -class _InventoryPickerDialog extends StatefulWidget { +class _InventoryPickerDialog extends ConsumerStatefulWidget { final List<_PickerItem> items; - const _InventoryPickerDialog({required this.items}); + final int? warehouseId; + const _InventoryPickerDialog({required this.items, this.warehouseId}); @override - State<_InventoryPickerDialog> createState() => _InventoryPickerDialogState(); + ConsumerState<_InventoryPickerDialog> createState() => + _InventoryPickerDialogState(); } -class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { +class _InventoryPickerDialogState + extends ConsumerState<_InventoryPickerDialog> { final _searchCtrl = TextEditingController(); String _search = ''; final Set _selected = {}; + // 服务端搜索结果(替代一次性拉全部本地过滤);初始用调用方已加载的首屏 + late List<_PickerItem> _results = widget.items; + // 已见过的条目(跨多次搜索),用于确认时按 productId 还原所选 + late final Map _known = { + for (final it in widget.items) it.productId: it + }; + bool _loading = false; + Timer? _debounce; @override void dispose() { + _debounce?.cancel(); _searchCtrl.dispose(); super.dispose(); } - List<_PickerItem> get _filtered { - if (_search.isEmpty) return widget.items; - final q = _search.toLowerCase(); - return widget.items - .where((item) => - item.productName.toLowerCase().contains(q) || - item.productCode.toLowerCase().contains(q) || - item.series.toLowerCase().contains(q)) - .toList(); + void _onSearchChanged(String v) { + setState(() => _search = v); + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), _fetch); + } + + Future _fetch() async { + setState(() => _loading = true); + try { + final kw = _search.trim(); + final result = await ref.read(inventoryRepositoryProvider).listInventory( + warehouseId: widget.warehouseId, + keyword: kw.isEmpty ? null : kw, + pageSize: AppConstants.stockOutPickerPageSize, + ); + final items = _aggregatePickerItems(result.data); + for (final it in items) { + _known[it.productId] = it; + } + if (mounted) setState(() => _results = items); + } catch (_) { + // 忽略:保留上次结果 + } finally { + if (mounted) setState(() => _loading = false); + } } @override Widget build(BuildContext context) { - final filtered = _filtered; + final filtered = _results; final allSelected = filtered.isNotEmpty && filtered.every((e) => _selected.contains(e.productId)); @@ -945,14 +998,24 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: TextField( controller: _searchCtrl, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: '搜索商品编码、名称或系列', - prefixIcon: Icon(Icons.search, size: 18), + prefixIcon: const Icon(Icons.search, size: 18), + suffixIcon: _loading + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, isDense: true, contentPadding: - EdgeInsets.symmetric(horizontal: 12, vertical: 10), + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), - onChanged: (v) => setState(() => _search = v), + onChanged: _onSearchChanged, ), ), // Table header @@ -1068,9 +1131,9 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { onPressed: _selected.isEmpty ? null : () { - final result = widget.items - .where((item) => - _selected.contains(item.productId)) + final result = _selected + .map((id) => _known[id]) + .whereType<_PickerItem>() .toList(); Navigator.pop(context, result); }, diff --git a/client/lib/widgets/searchable_option_field.dart b/client/lib/widgets/searchable_option_field.dart index e221cfb..69516cd 100644 --- a/client/lib/widgets/searchable_option_field.dart +++ b/client/lib/widgets/searchable_option_field.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import '../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import '../core/responsive/responsive.dart'; @@ -41,6 +43,9 @@ class SearchableOptionField extends StatelessWidget { /// 创建成功返回新选项 id(自动选中),失败/取消返回 null。为空则不显示新增入口。 final Future Function(String keyword)? onCreate; + /// 可选:服务端搜索。提供时搜索框输入会 debounce 调它取结果(替代本地过滤)。 + final Future> Function(String keyword)? onSearch; + const SearchableOptionField({ super.key, required this.options, @@ -51,6 +56,7 @@ class SearchableOptionField extends StatelessWidget { this.isRequired = false, this.isDense = true, this.onCreate, + this.onSearch, }); String get _displayText { @@ -66,6 +72,7 @@ class SearchableOptionField extends StatelessWidget { options: options, selectedId: selectedId, onCreate: onCreate, + onSearch: onSearch, ), ); // result == -1 means "clear selection" @@ -120,11 +127,13 @@ class _SearchDialog extends StatefulWidget { final List options; final int? selectedId; final Future Function(String keyword)? onCreate; + final Future> Function(String keyword)? onSearch; const _SearchDialog({ required this.title, required this.options, this.selectedId, this.onCreate, + this.onSearch, }); @override @@ -135,9 +144,28 @@ class _SearchDialogState extends State<_SearchDialog> { final _ctrl = TextEditingController(); String _keyword = ''; bool _creating = false; + List? _serverItems; // 服务端搜索结果(null=尚未搜索,用 widget.options) + bool _searching = false; + Timer? _debounce; + + void _onSearchChanged(String v) { + setState(() => _keyword = v); + if (widget.onSearch == null) return; + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () async { + setState(() => _searching = true); + try { + final res = await widget.onSearch!(v.trim()); + if (mounted) setState(() => _serverItems = res); + } finally { + if (mounted) setState(() => _searching = false); + } + }); + } @override void dispose() { + _debounce?.cancel(); _ctrl.dispose(); super.dispose(); } @@ -197,12 +225,14 @@ class _SearchDialogState extends State<_SearchDialog> { @override Widget build(BuildContext context) { - final filtered = _keyword.isEmpty - ? widget.options - : widget.options.where((o) => o.matches(_keyword)).toList(); + final filtered = widget.onSearch != null + ? (_serverItems ?? widget.options) + : (_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()); + filtered.any((o) => o.name.toLowerCase() == kw.toLowerCase()); final canCreate = widget.onCreate != null && kw.isNotEmpty && !hasExact; return AlertDialog( @@ -216,12 +246,22 @@ class _SearchDialogState extends State<_SearchDialog> { TextField( controller: _ctrl, autofocus: true, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: '搜索...', - prefixIcon: Icon(Icons.search, size: 18), + prefixIcon: const Icon(Icons.search, size: 18), + suffixIcon: _searching + ? const Padding( + padding: EdgeInsets.all(10), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, isDense: true, ), - onChanged: (v) => setState(() => _keyword = v), + onChanged: _onSearchChanged, ), const SizedBox(height: 8), Expanded(