From c5c74d59ab0c7ae0bfcc2ab519bb591d1b0e23a6 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 14 Apr 2026 00:57:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E7=AD=9B=E9=80=89=E9=A1=B9?= =?UTF-8?q?=E7=A7=BB=E5=85=A5=E5=88=97=E5=A4=B4=EF=BC=8Chover=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 FilterableColumnHeader widget:鼠标悬停列头时显示漏斗图标, 有活跃筛选时图标常驻并变为主色调,点击打开多选弹窗,活跃时右侧 显示一键清除按钮。 入库单/出库单列表:仓库、供应商/客户列头换用 FilterableColumnHeader, 工具栏从两行合并为一行(移除独立筛选按钮行,显示字段按钮移至右侧)。 Co-Authored-By: Claude Sonnet 4.6 --- .../stock_in/stock_in_list_screen.dart | 139 ++++++++---------- .../stock_out/stock_out_list_screen.dart | 139 ++++++++---------- client/lib/widgets/multi_select_dropdown.dart | 77 ++++++++++ 3 files changed, 195 insertions(+), 160 deletions(-) diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index acfa7e0..ec689b4 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -6,7 +6,7 @@ import '../../models/stock_in.dart'; import '../../providers/stock_in_provider.dart'; import '../../repositories/stock_in_repository.dart'; import '../../widgets/data_table_card.dart'; -import '../../widgets/multi_select_dropdown.dart'; +import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; @@ -162,12 +162,24 @@ class _StockInListScreenState extends ConsumerState { (c.minWidth == null || screenWidth >= c.minWidth!)) .toList(); - final columns = visibleCols - .map((c) => DataColumn( - label: Text(c.label), - numeric: c.key == 'amount', - )) - .toList(); + final columns = visibleCols.map((c) { + final label = switch (c.key) { + 'warehouse' => FilterableColumnHeader( + text: c.label, + options: warehouseOptions, + selected: _filterWarehouse, + onChanged: (v) => setState(() => _filterWarehouse = v), + ), + 'supplier' => FilterableColumnHeader( + text: c.label, + options: supplierOptions, + selected: _filterSupplier, + onChanged: (v) => setState(() => _filterSupplier = v), + ), + _ => Text(c.label), + }; + return DataColumn(label: label, numeric: c.key == 'amount'); + }).toList(); DataCell buildOrderCell(String key, StockInOrder o) { switch (key) { @@ -271,81 +283,48 @@ class _StockInListScreenState extends ConsumerState { page: page, onPageChanged: (p) => ref.read(stockInListProvider.notifier).setPage(p), - toolbar: Column( - crossAxisAlignment: CrossAxisAlignment.start, + toolbar: Row( children: [ - // Row 1: new button + status filter + date picker - Row( - children: [ - if (showNewButton) - ElevatedButton.icon( - onPressed: () => context.go('/stock-in/new'), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建入库审核单'), - ), - const Spacer(), - if (showStatusFilter) ...[ - _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) { - setState(() => _statusFilter = v ?? ''); - ref - .read(stockInListProvider.notifier) - .setStatus(v ?? ''); - }, - ), - const SizedBox(width: 8), - ], - OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null - ? '选择日期' - : '$_startDate ~ $_endDate', - style: const TextStyle(fontSize: 13), - ), - ), - if (_dateRange != null) ...[ - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.clear, size: 16), - onPressed: () { - setState(() => _dateRange = null); - ref - .read(stockInListProvider.notifier) - .setDateRange(null, null); - }, - ), - ], - ], + if (showNewButton) + ElevatedButton.icon( + onPressed: () => context.go('/stock-in/new'), + icon: const Icon(Icons.add, size: 16), + label: const Text('新建入库审核单'), + ), + const Spacer(), + if (showStatusFilter) ...[ + _StatusFilterDropdown( + value: _statusFilter, + onChanged: (v) { + setState(() => _statusFilter = v ?? ''); + ref.read(stockInListProvider.notifier).setStatus(v ?? ''); + }, + ), + const SizedBox(width: 8), + ], + OutlinedButton.icon( + onPressed: _pickDateRange, + icon: const Icon(Icons.date_range, size: 16), + label: Text( + _dateRange == null ? '选择日期' : '$_startDate ~ $_endDate', + style: const TextStyle(fontSize: 13), + ), ), - // Row 2: multi-select filters + column toggle - const SizedBox(height: 6), - Row( - children: [ - if (supplierOptions.length > 1) - MultiSelectDropdown( - label: '供应商', - options: supplierOptions, - selected: _filterSupplier, - onChanged: (v) => setState(() => _filterSupplier = v), - ), - if (supplierOptions.length > 1) const SizedBox(width: 8), - if (warehouseOptions.length > 1) - MultiSelectDropdown( - label: '仓库', - options: warehouseOptions, - selected: _filterWarehouse, - onChanged: (v) => setState(() => _filterWarehouse = v), - ), - const Spacer(), - ColumnToggleButton( - columns: _colDefs, - hidden: _hiddenCols, - onChanged: (v) => setState(() => _hiddenCols = v), - ), - ], + if (_dateRange != null) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + setState(() => _dateRange = null); + ref.read(stockInListProvider.notifier).setDateRange(null, null); + }, + ), + ], + const SizedBox(width: 8), + ColumnToggleButton( + columns: _colDefs, + hidden: _hiddenCols, + onChanged: (v) => setState(() => _hiddenCols = v), ), ], ), diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 172c59a..b04cffc 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -6,7 +6,7 @@ import '../../models/stock_out.dart'; import '../../providers/stock_out_provider.dart'; import '../../repositories/stock_out_repository.dart'; import '../../widgets/data_table_card.dart'; -import '../../widgets/multi_select_dropdown.dart'; +import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; @@ -163,12 +163,24 @@ class _StockOutListScreenState extends ConsumerState { (c.minWidth == null || screenWidth >= c.minWidth!)) .toList(); - final columns = visibleCols - .map((c) => DataColumn( - label: Text(c.label), - numeric: c.key == 'amount', - )) - .toList(); + final columns = visibleCols.map((c) { + final label = switch (c.key) { + 'warehouse' => FilterableColumnHeader( + text: c.label, + options: warehouseOptions, + selected: _filterWarehouse, + onChanged: (v) => setState(() => _filterWarehouse = v), + ), + 'customer' => FilterableColumnHeader( + text: c.label, + options: customerOptions, + selected: _filterCustomer, + onChanged: (v) => setState(() => _filterCustomer = v), + ), + _ => Text(c.label), + }; + return DataColumn(label: label, numeric: c.key == 'amount'); + }).toList(); DataCell buildOrderCell(String key, StockOutOrder o) { switch (key) { @@ -272,81 +284,48 @@ class _StockOutListScreenState extends ConsumerState { page: page, onPageChanged: (p) => ref.read(stockOutListProvider.notifier).setPage(p), - toolbar: Column( - crossAxisAlignment: CrossAxisAlignment.start, + toolbar: Row( children: [ - // Row 1: new button + status filter + date picker - Row( - children: [ - if (showNewButton) - ElevatedButton.icon( - onPressed: () => context.go('/stock-out/new'), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建出库审核单'), - ), - const Spacer(), - if (showStatusFilter) ...[ - _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) { - setState(() => _statusFilter = v ?? ''); - ref - .read(stockOutListProvider.notifier) - .setStatus(v ?? ''); - }, - ), - const SizedBox(width: 8), - ], - OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null - ? '选择日期' - : '$_startDate ~ $_endDate', - style: const TextStyle(fontSize: 13), - ), - ), - if (_dateRange != null) ...[ - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.clear, size: 16), - onPressed: () { - setState(() => _dateRange = null); - ref - .read(stockOutListProvider.notifier) - .setDateRange(null, null); - }, - ), - ], - ], + if (showNewButton) + ElevatedButton.icon( + onPressed: () => context.go('/stock-out/new'), + icon: const Icon(Icons.add, size: 16), + label: const Text('新建出库审核单'), + ), + const Spacer(), + if (showStatusFilter) ...[ + _StatusFilterDropdown( + value: _statusFilter, + onChanged: (v) { + setState(() => _statusFilter = v ?? ''); + ref.read(stockOutListProvider.notifier).setStatus(v ?? ''); + }, + ), + const SizedBox(width: 8), + ], + OutlinedButton.icon( + onPressed: _pickDateRange, + icon: const Icon(Icons.date_range, size: 16), + label: Text( + _dateRange == null ? '选择日期' : '$_startDate ~ $_endDate', + style: const TextStyle(fontSize: 13), + ), ), - // Row 2: multi-select filters + column toggle - const SizedBox(height: 6), - Row( - children: [ - if (customerOptions.length > 1) - MultiSelectDropdown( - label: '客户', - options: customerOptions, - selected: _filterCustomer, - onChanged: (v) => setState(() => _filterCustomer = v), - ), - if (customerOptions.length > 1) const SizedBox(width: 8), - if (warehouseOptions.length > 1) - MultiSelectDropdown( - label: '仓库', - options: warehouseOptions, - selected: _filterWarehouse, - onChanged: (v) => setState(() => _filterWarehouse = v), - ), - const Spacer(), - ColumnToggleButton( - columns: _colDefs, - hidden: _hiddenCols, - onChanged: (v) => setState(() => _hiddenCols = v), - ), - ], + if (_dateRange != null) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + setState(() => _dateRange = null); + ref.read(stockOutListProvider.notifier).setDateRange(null, null); + }, + ), + ], + const SizedBox(width: 8), + ColumnToggleButton( + columns: _colDefs, + hidden: _hiddenCols, + onChanged: (v) => setState(() => _hiddenCols = v), ), ], ), diff --git a/client/lib/widgets/multi_select_dropdown.dart b/client/lib/widgets/multi_select_dropdown.dart index 1314b46..8f6009d 100644 --- a/client/lib/widgets/multi_select_dropdown.dart +++ b/client/lib/widgets/multi_select_dropdown.dart @@ -143,6 +143,83 @@ class _MultiSelectDialogState extends State<_MultiSelectDialog> { } } +/// Column header widget that shows a filter icon on hover. +/// Clicking the icon opens the same multi-select dialog as [MultiSelectDropdown]. +/// Use as [DataColumn.label] for filterable columns. +class FilterableColumnHeader extends StatefulWidget { + final String text; + final List options; + final Set selected; + final ValueChanged> onChanged; + + const FilterableColumnHeader({ + super.key, + required this.text, + required this.options, + required this.selected, + required this.onChanged, + }); + + @override + State createState() => _FilterableColumnHeaderState(); +} + +class _FilterableColumnHeaderState extends State { + 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( + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.text), + 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: 2), + child: Icon( + active ? Icons.filter_alt : Icons.filter_alt_outlined, + size: 13, + color: active ? AppTheme.primary : AppTheme.textSecondary, + ), + ), + ), + ), + if (active) + InkWell( + onTap: () => widget.onChanged({}), + borderRadius: BorderRadius.circular(4), + child: const Icon(Icons.cancel, size: 12, color: AppTheme.primary), + ), + ], + ), + ); + } + + void _show(BuildContext context) { + showDialog( + context: context, + barrierColor: Colors.black12, + builder: (_) => _MultiSelectDialog( + label: widget.text, + options: widget.options, + initial: widget.selected, + onApply: widget.onChanged, + ), + ); + } +} + /// Column definition for column visibility toggle class ColDef { final String key;