feat(client): 筛选项移入列头,hover 显示
新增 FilterableColumnHeader widget:鼠标悬停列头时显示漏斗图标, 有活跃筛选时图标常驻并变为主色调,点击打开多选弹窗,活跃时右侧 显示一键清除按钮。 入库单/出库单列表:仓库、供应商/客户列头换用 FilterableColumnHeader, 工具栏从两行合并为一行(移除独立筛选按钮行,显示字段按钮移至右侧)。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<StockInListScreen> {
|
||||
(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<StockInListScreen> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<StockOutListScreen> {
|
||||
(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<StockOutListScreen> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<String> options;
|
||||
final Set<String> selected;
|
||||
final ValueChanged<Set<String>> onChanged;
|
||||
|
||||
const FilterableColumnHeader({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.options,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FilterableColumnHeader> createState() => _FilterableColumnHeaderState();
|
||||
}
|
||||
|
||||
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(
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user