chore: release client-v1.0.67
Deploy Client / build-client-web (push) Successful in 41s
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-macos (push) Successful in 2m33s
Deploy Client / build-android (push) Successful in 1m6s
Deploy Client / build-ios (push) Successful in 2m42s
Deploy Client / release-deploy-client (push) Successful in 1m27s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
wangjia
2026-06-21 17:33:32 +08:00
parent 7b67466a3e
commit 6d70bd3e37
4 changed files with 180 additions and 58 deletions
+5
View File
@@ -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/), 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). 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 ## [1.0.66] - 2026-06-21
### 改进 ### 改进
@@ -604,6 +604,20 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
hint: '请选择供应商', hint: '请选择供应商',
dialogTitle: '选择供应商', dialogTitle: '选择供应商',
isRequired: true, 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) => onChanged: (v) =>
setState(() => _partnerId = v), setState(() => _partnerId = v),
), ),
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/auth/auth_state.dart';
import '../../core/utils/print_util.dart'; import '../../core/utils/print_util.dart';
import '../../core/utils/date_util.dart'; import '../../core/utils/date_util.dart';
import '../../models/inventory.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import '../../widgets/date_picker_field.dart'; import '../../widgets/date_picker_field.dart';
import '../../widgets/searchable_option_field.dart'; import '../../widgets/searchable_option_field.dart';
@@ -39,6 +42,39 @@ class _PickerItem {
}); });
} }
/// 把库存行按 product 聚合为选择器条目(同一 product 的多行数量相加)。
List<_PickerItem> _aggregatePickerItems(List<Inventory> rows) {
final Map<int, _PickerItem> 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 { class _ItemRow {
int? productId; int? productId;
final String productCode; final String productCode;
@@ -158,36 +194,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
.listInventory( .listInventory(
warehouseId: warehouseId, warehouseId: warehouseId,
pageSize: AppConstants.stockOutPickerPageSize); pageSize: AppConstants.stockOutPickerPageSize);
final Map<int, _PickerItem> 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(() { setState(() {
_inventoryPickerItems = productMap.values.toList(); _inventoryPickerItems = _aggregatePickerItems(result.data);
_inventoryMap = { _inventoryMap = {
for (final item in _inventoryPickerItems) for (final item in _inventoryPickerItems)
item.productId: item.availableQty item.productId: item.availableQty
@@ -206,7 +214,10 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
} }
final selected = await showDialog<List<_PickerItem>>( final selected = await showDialog<List<_PickerItem>>(
context: context, context: context,
builder: (_) => _InventoryPickerDialog(items: _inventoryPickerItems), builder: (_) => _InventoryPickerDialog(
items: _inventoryPickerItems,
warehouseId: _warehouseId,
),
); );
if (selected == null || selected.isEmpty) return; if (selected == null || selected.isEmpty) return;
setState(() { setState(() {
@@ -532,6 +543,20 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
selectedId: _partnerId, selectedId: _partnerId,
hint: '请选择客户', hint: '请选择客户',
dialogTitle: '选择客户', 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) => onChanged: (v) =>
setState(() => _partnerId = v), setState(() => _partnerId = v),
), ),
@@ -873,39 +898,67 @@ class _FormField extends StatelessWidget {
} }
} }
class _InventoryPickerDialog extends StatefulWidget { class _InventoryPickerDialog extends ConsumerStatefulWidget {
final List<_PickerItem> items; final List<_PickerItem> items;
const _InventoryPickerDialog({required this.items}); final int? warehouseId;
const _InventoryPickerDialog({required this.items, this.warehouseId});
@override @override
State<_InventoryPickerDialog> createState() => _InventoryPickerDialogState(); ConsumerState<_InventoryPickerDialog> createState() =>
_InventoryPickerDialogState();
} }
class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { class _InventoryPickerDialogState
extends ConsumerState<_InventoryPickerDialog> {
final _searchCtrl = TextEditingController(); final _searchCtrl = TextEditingController();
String _search = ''; String _search = '';
final Set<int> _selected = {}; final Set<int> _selected = {};
// 服务端搜索结果(替代一次性拉全部本地过滤);初始用调用方已加载的首屏
late List<_PickerItem> _results = widget.items;
// 已见过的条目(跨多次搜索),用于确认时按 productId 还原所选
late final Map<int, _PickerItem> _known = {
for (final it in widget.items) it.productId: it
};
bool _loading = false;
Timer? _debounce;
@override @override
void dispose() { void dispose() {
_debounce?.cancel();
_searchCtrl.dispose(); _searchCtrl.dispose();
super.dispose(); super.dispose();
} }
List<_PickerItem> get _filtered { void _onSearchChanged(String v) {
if (_search.isEmpty) return widget.items; setState(() => _search = v);
final q = _search.toLowerCase(); _debounce?.cancel();
return widget.items _debounce = Timer(const Duration(milliseconds: 300), _fetch);
.where((item) => }
item.productName.toLowerCase().contains(q) ||
item.productCode.toLowerCase().contains(q) || Future<void> _fetch() async {
item.series.toLowerCase().contains(q)) setState(() => _loading = true);
.toList(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filtered = _filtered; final filtered = _results;
final allSelected = filtered.isNotEmpty && final allSelected = filtered.isNotEmpty &&
filtered.every((e) => _selected.contains(e.productId)); filtered.every((e) => _selected.contains(e.productId));
@@ -945,14 +998,24 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: TextField( child: TextField(
controller: _searchCtrl, controller: _searchCtrl,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: '搜索商品编码、名称或系列', 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, isDense: true,
contentPadding: contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 10), const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
), ),
onChanged: (v) => setState(() => _search = v), onChanged: _onSearchChanged,
), ),
), ),
// Table header // Table header
@@ -1068,9 +1131,9 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
onPressed: _selected.isEmpty onPressed: _selected.isEmpty
? null ? null
: () { : () {
final result = widget.items final result = _selected
.where((item) => .map((id) => _known[id])
_selected.contains(item.productId)) .whereType<_PickerItem>()
.toList(); .toList();
Navigator.pop(context, result); Navigator.pop(context, result);
}, },
@@ -1,3 +1,5 @@
import 'dart:async';
import '../core/utils/dialog_util.dart'; import '../core/utils/dialog_util.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../core/responsive/responsive.dart'; import '../core/responsive/responsive.dart';
@@ -41,6 +43,9 @@ class SearchableOptionField extends StatelessWidget {
/// 创建成功返回新选项 id(自动选中),失败/取消返回 null。为空则不显示新增入口。 /// 创建成功返回新选项 id(自动选中),失败/取消返回 null。为空则不显示新增入口。
final Future<int?> Function(String keyword)? onCreate; final Future<int?> Function(String keyword)? onCreate;
/// 可选:服务端搜索。提供时搜索框输入会 debounce 调它取结果(替代本地过滤)。
final Future<List<OptionItem>> Function(String keyword)? onSearch;
const SearchableOptionField({ const SearchableOptionField({
super.key, super.key,
required this.options, required this.options,
@@ -51,6 +56,7 @@ class SearchableOptionField extends StatelessWidget {
this.isRequired = false, this.isRequired = false,
this.isDense = true, this.isDense = true,
this.onCreate, this.onCreate,
this.onSearch,
}); });
String get _displayText { String get _displayText {
@@ -66,6 +72,7 @@ class SearchableOptionField extends StatelessWidget {
options: options, options: options,
selectedId: selectedId, selectedId: selectedId,
onCreate: onCreate, onCreate: onCreate,
onSearch: onSearch,
), ),
); );
// result == -1 means "clear selection" // result == -1 means "clear selection"
@@ -120,11 +127,13 @@ class _SearchDialog extends StatefulWidget {
final List<OptionItem> options; final List<OptionItem> options;
final int? selectedId; final int? selectedId;
final Future<int?> Function(String keyword)? onCreate; final Future<int?> Function(String keyword)? onCreate;
final Future<List<OptionItem>> Function(String keyword)? onSearch;
const _SearchDialog({ const _SearchDialog({
required this.title, required this.title,
required this.options, required this.options,
this.selectedId, this.selectedId,
this.onCreate, this.onCreate,
this.onSearch,
}); });
@override @override
@@ -135,9 +144,28 @@ class _SearchDialogState extends State<_SearchDialog> {
final _ctrl = TextEditingController(); final _ctrl = TextEditingController();
String _keyword = ''; String _keyword = '';
bool _creating = false; bool _creating = false;
List<OptionItem>? _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 @override
void dispose() { void dispose() {
_debounce?.cancel();
_ctrl.dispose(); _ctrl.dispose();
super.dispose(); super.dispose();
} }
@@ -197,12 +225,14 @@ class _SearchDialogState extends State<_SearchDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filtered = _keyword.isEmpty final filtered = widget.onSearch != null
? widget.options ? (_serverItems ?? widget.options)
: widget.options.where((o) => o.matches(_keyword)).toList(); : (_keyword.isEmpty
? widget.options
: widget.options.where((o) => o.matches(_keyword)).toList());
final kw = _keyword.trim(); final kw = _keyword.trim();
final hasExact = 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; final canCreate = widget.onCreate != null && kw.isNotEmpty && !hasExact;
return AlertDialog( return AlertDialog(
@@ -216,12 +246,22 @@ class _SearchDialogState extends State<_SearchDialog> {
TextField( TextField(
controller: _ctrl, controller: _ctrl,
autofocus: true, autofocus: true,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: '搜索...', 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, isDense: true,
), ),
onChanged: (v) => setState(() => _keyword = v), onChanged: _onSearchChanged,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(