feat(client): 库存查询新增规格/系列/入库时间;筛选组件支持搜索+滚动+标签

- 库存查询加规格、系列服务端筛选(后端 IN 条件,前端从 product-options API 加载全量选项)
- 库存查询新增「入库时间」列,导出 Excel 同步带上
- 入库单商品明细:隐藏商品编码列,生产日期从选填折叠区移至主行常显
- 筛选弹窗重构:顶部搜索框实时过滤 + 滚动列表(ConstrainedBox maxHeight)根治溢出
- 已选项以 Chip 标签置顶展示,每个标签带 × 可单独取消
- FilterableColumnHeader 筛选图标改为常驻,不再需要 hover 才显示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-15 11:15:43 +08:00
parent 316e9b6fc9
commit eac3e8825c
8 changed files with 316 additions and 179 deletions
@@ -20,6 +20,8 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
int _pageSize = 20;
int? _warehouseId;
String _keyword = '';
List<String> _series = [];
List<String> _spec = [];
PageResult<Inventory>? _cache;
@override
@@ -40,6 +42,8 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
return ref.read(inventoryRepositoryProvider).listInventory(
warehouseId: _warehouseId,
keyword: _keyword.isEmpty ? null : _keyword,
series: _series.isEmpty ? null : _series,
spec: _spec.isEmpty ? null : _spec,
page: _page,
pageSize: _pageSize,
);
@@ -68,6 +72,18 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
reload();
}
void setSeries(List<String> series) {
_series = series;
_page = 1;
reload();
}
void setSpec(List<String> spec) {
_spec = spec;
_page = 1;
reload();
}
void reload() {
state = const AsyncValue.loading();
_fetch().then((result) {
@@ -12,6 +12,8 @@ class InventoryRepository {
Future<PageResult<Inventory>> listInventory({
int? warehouseId,
String? keyword,
List<String>? series,
List<String>? spec,
int page = 1,
int pageSize = 50,
}) async {
@@ -21,6 +23,8 @@ class InventoryRepository {
'page_size': pageSize,
if (warehouseId != null) 'warehouse_id': warehouseId,
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
if (series != null && series.isNotEmpty) 'series': series.join(','),
if (spec != null && spec.isNotEmpty) 'spec': spec.join(','),
};
final resp = await _client.get('/inventory', params: params);
return PageResult.fromJson(
@@ -17,6 +17,7 @@ import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../providers/product_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/shop_provider.dart' show shopInfoProvider;
@@ -31,6 +32,9 @@ class InventoryListScreen extends ConsumerStatefulWidget {
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final _searchCtrl = TextEditingController();
Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {};
Set<String> _filterSeries = {};
@override
void dispose() {
@@ -365,18 +369,24 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
items.where((i) => i.minStock != null && i.quantity < i.minStock!).length;
final emptyCount = items.where((i) => i.quantity == 0).length;
// 仓库选项从数据中派生客户端筛选
// 仓库选项从当前页派生客户端筛选),系列/规格从 product-options API 加载(服务端筛选)
final warehouseOptions = items
.map((i) => i.warehouseName)
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final seriesOptions = ref.watch(productSeriesListProvider).valueOrNull
?.map((s) => s.name)
.toList() ??
[];
final specOptions = ref.watch(productSpecListProvider).valueOrNull
?.map((s) => s.name)
.toList() ??
[];
final filteredItems = _filterWarehouse.isEmpty
? items
: items
.where((i) => _filterWarehouse.contains(i.warehouseName))
.toList();
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
return Column(
children: [
@@ -440,7 +450,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '库存查询',
headers: ['商品编码', '商品名称', '规格', '批次号', '仓库', '库存量', '单价', '生产日期', '供应商', '安全库存', '状态'],
headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'],
rows: filteredItems.map((item) {
final status = item.quantity == 0
? '缺货'
@@ -451,11 +461,13 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
item.productCode.isEmpty ? '' : item.productCode,
item.productName.isEmpty ? '' : item.productName,
item.spec.isEmpty ? '' : item.spec,
item.series.isEmpty ? '' : item.series,
item.batchNo.isEmpty ? '' : item.batchNo,
item.warehouseName.isEmpty ? '' : item.warehouseName,
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
item.unitPrice != null ? item.unitPrice!.toStringAsFixed(2) : '',
item.productionDate ?? '',
item.createdAt != null && item.createdAt!.length >= 10 ? item.createdAt!.substring(0, 10) : '',
item.supplierName.isEmpty ? '' : item.supplierName,
item.minStock ?? '',
status,
@@ -490,8 +502,28 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
columns: [
const DataColumn(label: Text('商品编码')),
const DataColumn(label: Text('商品名称')),
const DataColumn(label: Text('规格')),
const DataColumn(label: Text('系列')),
DataColumn(
label: FilterableColumnHeader(
text: '规格',
options: specOptions,
selected: _filterSpec,
onChanged: (v) {
setState(() => _filterSpec = v);
ref.read(inventoryListProvider.notifier).setSpec(v.toList());
},
),
),
DataColumn(
label: FilterableColumnHeader(
text: '系列',
options: seriesOptions,
selected: _filterSeries,
onChanged: (v) {
setState(() => _filterSeries = v);
ref.read(inventoryListProvider.notifier).setSeries(v.toList());
},
),
),
const DataColumn(label: Text('批次号')),
DataColumn(
label: FilterableColumnHeader(
@@ -504,6 +536,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
const DataColumn(label: Text('库存量'), numeric: true),
const DataColumn(label: Text('单价'), numeric: true),
const DataColumn(label: Text('生产日期')),
const DataColumn(label: Text('入库时间')),
const DataColumn(label: Text('供应商')),
const DataColumn(label: Text('备注')),
const DataColumn(label: Text('状态')),
@@ -527,6 +560,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: filteredItems
@@ -590,6 +624,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
: '-',
)),
DataCell(Text(item.productionDate ?? '-')),
DataCell(Text(
item.createdAt != null && item.createdAt!.length >= 10
? item.createdAt!.substring(0, 10)
: '-',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
)),
DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)),
DataCell(
Tooltip(
@@ -842,10 +842,10 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
color: const Color(0xFFF0F4FF),
child: Row(children: [
SizedBox(width: 36, child: th('序号')),
Expanded(flex: 12, 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: 10, child: th('数量')),
Expanded(flex: 10, child: th('单价')),
@@ -875,7 +875,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
);
final hasOptional = item.batchNoCtrl.text.isNotEmpty ||
item.productionDate != null ||
item.selectedOriginId != null ||
item.selectedShelfLifeId != null ||
item.selectedStorageId != null ||
@@ -891,9 +890,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
SizedBox(
width: 36,
child: tc('${index + 1}', color: AppTheme.textSecondary)),
Expanded(
flex: 12,
child: tc(productCode, color: AppTheme.textSecondary)),
Expanded(
flex: 20,
child: Padding(
@@ -909,6 +905,11 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
child: Padding(
padding: const EdgeInsets.all(4),
child: _specField(item))),
Expanded(
flex: 12,
child: Padding(
padding: const EdgeInsets.all(4),
child: _dateField(item))),
Expanded(
flex: 9,
child: tc(specQty > 0 ? '$specQty' : '-',
@@ -994,8 +995,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
children: [
_OptionalField(
label: '批次号', width: 180, child: _batchField(item)),
_OptionalField(
label: '生产日期', width: 160, child: _dateField(item)),
_OptionalField(
label: '产地', width: 180, child: _originField(item)),
_OptionalField(
@@ -1022,7 +1021,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
return MobileListCard(
title: Text('商品 ${index + 1}'),
subtitle: productCode.isNotEmpty ? Text('编码 $productCode') : null,
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger),
onPressed: _items.length > 1 ? () => _removeItem(index) : null,
@@ -1033,6 +1031,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
MobileCardField('名称', null, valueWidget: _nameField(item)),
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)),
@@ -1051,8 +1050,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
)),
if (item.expanded)
MobileCardField('批次号', null, valueWidget: _batchField(item)),
if (item.expanded)
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
if (item.expanded)
MobileCardField('产地', null, valueWidget: _originField(item)),
if (item.expanded)
+115 -55
View File
@@ -83,6 +83,8 @@ class _MultiSelectDialog extends StatefulWidget {
class _MultiSelectDialogState extends State<_MultiSelectDialog> {
late Set<String> _selected;
final _searchCtrl = TextEditingController();
String _search = '';
@override
void initState() {
@@ -90,40 +92,109 @@ class _MultiSelectDialogState extends State<_MultiSelectDialog> {
_selected = Set.from(widget.initial);
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final filtered = _search.isEmpty
? widget.options
: widget.options
.where((o) => o.toLowerCase().contains(_search.toLowerCase()))
.toList();
return AlertDialog(
title: Text(widget.label, style: const TextStyle(fontSize: 15)),
contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
content: SizedBox(
width: 220,
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 搜索框
TextField(
controller: _searchCtrl,
autofocus: true,
decoration: const InputDecoration(
hintText: '搜索…',
prefixIcon: Icon(Icons.search, size: 16),
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
style: const TextStyle(fontSize: 13),
onChanged: (v) => setState(() => _search = v),
),
const SizedBox(height: 8),
// 已选标签区
if (_selected.isNotEmpty)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 96),
child: SingleChildScrollView(
child: Wrap(
spacing: 6,
runSpacing: 4,
children: _selected.map((s) => Chip(
label: Text(s, style: const TextStyle(fontSize: 12)),
deleteIcon: const Icon(Icons.close, size: 14),
onDeleted: () => setState(() => _selected.remove(s)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
backgroundColor: AppTheme.primary.withOpacity(0.08),
side: BorderSide(color: AppTheme.primary.withOpacity(0.3), width: 0.5),
labelPadding: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
)).toList(),
),
),
),
if (_selected.isNotEmpty) const SizedBox(height: 6),
// 全选 / 清空
Row(children: [
TextButton(
onPressed: () =>
setState(() => _selected = Set.from(widget.options)),
onPressed: () => setState(() => _selected.addAll(filtered)),
child: const Text('全选', style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: () => setState(() => _selected = {}),
onPressed: () => setState(() => _selected.clear()),
child: const Text('清空', style: TextStyle(fontSize: 12)),
),
]),
const Divider(height: 1),
...widget.options.map((opt) => CheckboxListTile(
title: Text(opt, style: const TextStyle(fontSize: 13)),
value: _selected.contains(opt),
dense: true,
onChanged: (v) => setState(() {
if (v == true) {
_selected.add(opt);
} else {
_selected.remove(opt);
}
}),
)),
// 选项列表(有约束 + 滚动)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: filtered.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text('无匹配项',
style: TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
),
)
: ListView(
shrinkWrap: true,
children: filtered
.map((opt) => CheckboxListTile(
title: Text(opt,
style: const TextStyle(fontSize: 13)),
value: _selected.contains(opt),
dense: true,
onChanged: (v) => setState(() {
if (v == true) {
_selected.add(opt);
} else {
_selected.remove(opt);
}
}),
))
.toList(),
),
),
],
),
),
@@ -166,50 +237,39 @@ class FilterableColumnHeader extends StatefulWidget {
}
class _FilterableColumnHeaderState extends State<FilterableColumnHeader> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final active = widget.selected.isNotEmpty;
final showIcon = _hovered || active;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.text),
Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
opacity: showIcon ? 1.0 : 0.0,
duration: const Duration(milliseconds: 120),
child: InkWell(
onTap: widget.options.isEmpty ? null : () => _show(context),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Icon(
active ? Icons.filter_alt : Icons.filter_alt_outlined,
size: 16,
color: active ? AppTheme.primary : AppTheme.textSecondary,
),
),
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.text),
Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: widget.options.isEmpty ? null : () => _show(context),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Icon(
active ? Icons.filter_alt : Icons.filter_alt_outlined,
size: 16,
color: active ? AppTheme.primary : AppTheme.textSecondary,
),
),
if (active)
InkWell(
onTap: () => widget.onChanged({}),
borderRadius: BorderRadius.circular(4),
child: const Icon(Icons.cancel, size: 14,
color: AppTheme.primary),
),
],
),
],
),
),
if (active)
InkWell(
onTap: () => widget.onChanged({}),
borderRadius: BorderRadius.circular(4),
child: const Icon(Icons.cancel, size: 14,
color: AppTheme.primary),
),
],
),
],
);
}