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
+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),
),
],
),
],
);
}