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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user