feat(client): 所有表格列头加筛选,图标更大靠右对齐

FilterableColumnHeader:
- mainAxisAlignment.spaceBetween 将图标推到列头右侧
- 漏斗图标从 13px 增大到 16px,清除图标 14px

各页面改动:
- batch_tracking:状态/仓库/供应商筛选移入列头,工具栏合并为一行
- finance:类型/往来单位筛选移入列头,工具栏保留月份选择和显示字段
- inventory:仓库筛选从 API 端单选改为客户端多选,移入"仓库"列头
- products:品牌列头加筛选,从加载数据中动态派生选项

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-18 19:00:03 +08:00
parent c5c74d59ab
commit 5a3907e3f9
5 changed files with 170 additions and 191 deletions
+36 -44
View File
@@ -4,7 +4,7 @@ import '../../core/theme/app_theme.dart';
import '../../models/finance.dart'; import '../../models/finance.dart';
import '../../providers/finance_provider.dart'; import '../../providers/finance_provider.dart';
import '../../widgets/data_table_card.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/page_scaffold.dart';
class FinanceScreen extends ConsumerWidget { class FinanceScreen extends ConsumerWidget {
@@ -161,12 +161,26 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
(c.minWidth == null || screenWidth >= c.minWidth!)) (c.minWidth == null || screenWidth >= c.minWidth!))
.toList(); .toList();
final columns = visibleCols final columns = visibleCols.map((c) {
.map((c) => DataColumn( final label = switch (c.key) {
label: Text(c.label), 'type' => FilterableColumnHeader(
numeric: c.key == 'amount' || c.key == 'balance', text: c.label,
)) options: typeOptions,
.toList(); selected: _filterType,
onChanged: (v) => setState(() => _filterType = v),
),
'partner' => FilterableColumnHeader(
text: c.label,
options: partnerOptions,
selected: _filterPartner,
onChanged: (v) => setState(() => _filterPartner = v),
),
_ => Text(c.label),
};
return DataColumn(
label: label,
numeric: c.key == 'amount' || c.key == 'balance');
}).toList();
DataCell buildFinanceCell(String key, FinanceRecord r) { DataCell buildFinanceCell(String key, FinanceRecord r) {
switch (key) { switch (key) {
@@ -284,44 +298,22 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
_fetch(); _fetch();
}); });
}, },
toolbar: Column( toolbar: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Filter bar + month selector + column toggle const Spacer(),
Row( _MonthSelector(
children: [ value: _month,
if (typeOptions.length > 1) onChanged: (v) {
MultiSelectDropdown( _month = v;
label: '类型', _page = 1;
options: typeOptions, _refetch();
selected: _filterType, },
onChanged: (v) => setState(() => _filterType = v), ),
), const SizedBox(width: 8),
if (typeOptions.length > 1) const SizedBox(width: 8), ColumnToggleButton(
if (partnerOptions.length > 1) columns: _colDefs,
MultiSelectDropdown( hidden: _hiddenCols,
label: '往来单位', onChanged: (v) => setState(() => _hiddenCols = v),
options: partnerOptions,
selected: _filterPartner,
onChanged: (v) =>
setState(() => _filterPartner = v),
),
const Spacer(),
_MonthSelector(
value: _month,
onChanged: (v) {
_month = v;
_page = 1;
_refetch();
},
),
const SizedBox(width: 8),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
],
), ),
], ],
), ),
@@ -4,7 +4,7 @@ import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart'; import '../../models/inventory.dart';
import '../../providers/inventory_provider.dart'; import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart'; import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
class BatchTrackingScreen extends ConsumerStatefulWidget { class BatchTrackingScreen extends ConsumerStatefulWidget {
const BatchTrackingScreen({super.key}); const BatchTrackingScreen({super.key});
@@ -135,12 +135,32 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
(c.minWidth == null || screenWidth >= c.minWidth!)) (c.minWidth == null || screenWidth >= c.minWidth!))
.toList(); .toList();
final columns = visibleCols final columns = visibleCols.map((c) {
.map((c) => DataColumn( final label = switch (c.key) {
label: Text(c.label), 'status' => FilterableColumnHeader(
numeric: c.key == 'qty' || c.key == 'price', text: c.label,
)) options: const ['在售', '已卖出'],
.toList(); selected: _filterStatus,
onChanged: (v) => setState(() => _filterStatus = v),
),
'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 == 'qty' || c.key == 'price');
}).toList();
final rows = records.isEmpty final rows = records.isEmpty
? [ ? [
@@ -169,59 +189,24 @@ class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
_page = p; _page = p;
_fetch(); _fetch();
}), }),
toolbar: Column( toolbar: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Top row: description + refresh + column toggle const Text('已审核入库商品(含库存与销售状态)',
Row( style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
children: [ const Spacer(),
const Text('已审核入库商品(含库存与销售状态)', IconButton(
style: TextStyle( icon: const Icon(Icons.refresh, size: 18),
fontSize: 13, color: AppTheme.textSecondary)), onPressed: () => setState(() {
const Spacer(), _page = 1;
IconButton( _fetch();
icon: const Icon(Icons.refresh, size: 18), }),
onPressed: () => setState(() { tooltip: '刷新',
_page = 1;
_fetch();
}),
tooltip: '刷新',
),
const SizedBox(width: 4),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
],
), ),
// Filter bar row const SizedBox(width: 4),
const SizedBox(height: 6), ColumnToggleButton(
Row( columns: _colDefs,
children: [ hidden: _hiddenCols,
MultiSelectDropdown( onChanged: (v) => setState(() => _hiddenCols = v),
label: '状态',
options: const ['在售', '已卖出'],
selected: _filterStatus,
onChanged: (v) => setState(() => _filterStatus = v),
),
const SizedBox(width: 8),
if (warehouseOptions.length > 1)
MultiSelectDropdown(
label: '仓库',
options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) => setState(() => _filterWarehouse = v),
),
if (warehouseOptions.length > 1) const SizedBox(width: 8),
if (supplierOptions.length > 1)
MultiSelectDropdown(
label: '供应商',
options: supplierOptions,
selected: _filterSupplier,
onChanged: (v) => setState(() => _filterSupplier = v),
),
],
), ),
], ],
), ),
@@ -5,8 +5,8 @@ import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart'; import '../../models/inventory.dart';
import '../../providers/inventory_provider.dart'; import '../../providers/inventory_provider.dart';
import '../../providers/warehouse_provider.dart';
import '../../widgets/data_table_card.dart'; import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
import '../../widgets/page_scaffold.dart'; import '../../widgets/page_scaffold.dart';
class InventoryListScreen extends ConsumerStatefulWidget { class InventoryListScreen extends ConsumerStatefulWidget {
@@ -20,7 +20,7 @@ class InventoryListScreen extends ConsumerStatefulWidget {
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> { class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final _searchCtrl = TextEditingController(); final _searchCtrl = TextEditingController();
Timer? _debounce; Timer? _debounce;
int? _warehouseFilter; Set<String> _filterWarehouse = {};
@override @override
void dispose() { void dispose() {
@@ -55,7 +55,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Widget _buildInventoryTab() { Widget _buildInventoryTab() {
final asyncInventory = ref.watch(inventoryListProvider); final asyncInventory = ref.watch(inventoryListProvider);
final asyncWarehouses = ref.watch(warehouseListProvider);
return asyncInventory.when( return asyncInventory.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
@@ -82,6 +81,19 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
items.where((i) => i.minStock != null && i.quantity < i.minStock!).length; items.where((i) => i.minStock != null && i.quantity < i.minStock!).length;
final emptyCount = items.where((i) => i.quantity == 0).length; final emptyCount = items.where((i) => i.quantity == 0).length;
// 仓库选项从数据中派生,客户端筛选
final warehouseOptions = items
.map((i) => i.warehouseName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final filteredItems = _filterWarehouse.isEmpty
? items
: items
.where((i) => _filterWarehouse.contains(i.warehouseName ?? ''))
.toList();
return Column( return Column(
children: [ children: [
// Summary cards // Summary cards
@@ -116,7 +128,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: DataTableCard( child: DataTableCard(
totalCount: result.total, totalCount: filteredItems.length,
page: result.page, page: result.page,
onPageChanged: (p) => onPageChanged: (p) =>
ref.read(inventoryListProvider.notifier).setPage(p), ref.read(inventoryListProvider.notifier).setPage(p),
@@ -128,49 +140,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
label: const Text('发起盘点'), label: const Text('发起盘点'),
), ),
const Spacer(), const Spacer(),
// Warehouse filter
asyncWarehouses.when(
loading: () => const SizedBox(),
error: (_, __) => const SizedBox(),
data: (warehouses) => Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.border),
borderRadius: BorderRadius.circular(4),
color: AppTheme.surface,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int?>(
value: _warehouseFilter,
hint: const Text('全部仓库',
style: TextStyle(fontSize: 13)),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('全部仓库',
style: TextStyle(fontSize: 13))),
...warehouses.map((w) =>
DropdownMenuItem<int?>(
value: w.id,
child: Text(w.name,
style: const TextStyle(
fontSize: 13)))),
],
onChanged: (v) {
setState(() => _warehouseFilter = v);
ref
.read(inventoryListProvider.notifier)
.setWarehouseId(v);
},
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary),
),
),
),
),
const SizedBox(width: 8),
SizedBox( SizedBox(
width: 200, width: 200,
child: TextField( child: TextField(
@@ -185,15 +154,22 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
), ),
], ],
), ),
columns: const [ columns: [
DataColumn(label: Text('商品编码')), const DataColumn(label: Text('商品编码')),
DataColumn(label: Text('商品名称')), const DataColumn(label: Text('商品名称')),
DataColumn(label: Text('品牌')), const DataColumn(label: Text('品牌')),
DataColumn(label: Text('规格')), const DataColumn(label: Text('规格')),
DataColumn(label: Text('仓库')), DataColumn(
DataColumn(label: Text('库存'), numeric: true), label: FilterableColumnHeader(
DataColumn(label: Text('安全库存'), numeric: true), text: '仓库',
DataColumn(label: Text('状态')), options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) => setState(() => _filterWarehouse = v),
),
),
const DataColumn(label: Text('库存'), numeric: true),
const DataColumn(label: Text('安全库存'), numeric: true),
const DataColumn(label: Text('状态')),
], ],
rows: items.isEmpty rows: items.isEmpty
? [ ? [
@@ -210,7 +186,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
DataCell(SizedBox()), DataCell(SizedBox()),
]) ])
] ]
: items : filteredItems
.map((item) => DataRow( .map((item) => DataRow(
color: WidgetStateProperty.resolveWith( color: WidgetStateProperty.resolveWith(
(states) { (states) {
@@ -5,6 +5,7 @@ import '../../core/theme/app_theme.dart';
import '../../models/product.dart'; import '../../models/product.dart';
import '../../providers/product_provider.dart'; import '../../providers/product_provider.dart';
import '../../widgets/data_table_card.dart'; import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
import '../../widgets/page_scaffold.dart'; import '../../widgets/page_scaffold.dart';
class ProductsScreen extends ConsumerStatefulWidget { class ProductsScreen extends ConsumerStatefulWidget {
@@ -17,6 +18,7 @@ class ProductsScreen extends ConsumerStatefulWidget {
class _ProductsScreenState extends ConsumerState<ProductsScreen> { class _ProductsScreenState extends ConsumerState<ProductsScreen> {
final _searchCtrl = TextEditingController(); final _searchCtrl = TextEditingController();
Timer? _debounce; Timer? _debounce;
Set<String> _filterBrand = {};
@override @override
void dispose() { void dispose() {
@@ -133,6 +135,17 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
Widget _buildProductList( Widget _buildProductList(
List<Product> products, int totalCount, int page) { List<Product> products, int totalCount, int page) {
// 品牌选项从当前数据中派生,客户端筛选
final brandOptions = products
.map((p) => p.brand ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final filteredProducts = _filterBrand.isEmpty
? products
: products.where((p) => _filterBrand.contains(p.brand ?? '')).toList();
return DataTableCard( return DataTableCard(
totalCount: totalCount, totalCount: totalCount,
page: page, page: page,
@@ -160,18 +173,25 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
), ),
], ],
), ),
columns: const [ columns: [
DataColumn(label: Text('商品编码')), const DataColumn(label: Text('商品编码')),
DataColumn(label: Text('商品名称')), const DataColumn(label: Text('商品名称')),
DataColumn(label: Text('品牌')), DataColumn(
DataColumn(label: Text('规格')), label: FilterableColumnHeader(
DataColumn(label: Text('单位')), text: '品牌',
DataColumn(label: Text('进价'), numeric: true), options: brandOptions,
DataColumn(label: Text('售价'), numeric: true), selected: _filterBrand,
DataColumn(label: Text('安全库存'), numeric: true), onChanged: (v) => setState(() => _filterBrand = v),
DataColumn(label: Text('操作')), ),
),
const DataColumn(label: Text('规格')),
const DataColumn(label: Text('单位')),
const DataColumn(label: Text('进价'), numeric: true),
const DataColumn(label: Text('售价'), numeric: true),
const DataColumn(label: Text('安全库存'), numeric: true),
const DataColumn(label: Text('操作')),
], ],
rows: products.isEmpty rows: filteredProducts.isEmpty
? [ ? [
const DataRow(cells: [ const DataRow(cells: [
DataCell(SizedBox()), DataCell(SizedBox()),
@@ -186,7 +206,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
DataCell(SizedBox()), DataCell(SizedBox()),
]) ])
] ]
: products : filteredProducts
.map((p) => DataRow( .map((p) => DataRow(
cells: [ cells: [
DataCell(Text(p.code, DataCell(Text(p.code,
+26 -20
View File
@@ -176,31 +176,37 @@ class _FilterableColumnHeaderState extends State<FilterableColumnHeader> {
onEnter: (_) => setState(() => _hovered = true), onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(widget.text), Text(widget.text),
AnimatedOpacity( Row(
opacity: showIcon ? 1.0 : 0.0, mainAxisSize: MainAxisSize.min,
duration: const Duration(milliseconds: 120), children: [
child: InkWell( AnimatedOpacity(
onTap: widget.options.isEmpty ? null : () => _show(context), opacity: showIcon ? 1.0 : 0.0,
borderRadius: BorderRadius.circular(4), duration: const Duration(milliseconds: 120),
child: Padding( child: InkWell(
padding: const EdgeInsets.symmetric(horizontal: 2), onTap: widget.options.isEmpty ? null : () => _show(context),
child: Icon( borderRadius: BorderRadius.circular(4),
active ? Icons.filter_alt : Icons.filter_alt_outlined, child: Padding(
size: 13, padding: const EdgeInsets.symmetric(horizontal: 3),
color: active ? AppTheme.primary : AppTheme.textSecondary, 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: 12, color: AppTheme.primary),
),
], ],
), ),
); );