feat(client): 库存规格/系列筛选改可搜索单选下拉、仓库单选,修复下拉过宽过高溢出

原型与代码同提交(design-first):下拉宽度锚定触发器宽 max(宽,220)、列表 264 封顶超出滚动、底部计数常驻。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
This commit is contained in:
wangjia
2026-08-28 17:30:55 +08:00
parent a1a3118c88
commit 801b8528ec
4 changed files with 283 additions and 52 deletions
+243 -17
View File
@@ -6,6 +6,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/theme/app_tokens.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
@@ -25,28 +26,41 @@ class DsMenuItem<T> {
}
/// 单选菜单:点击项返回其 value 并关闭;点外部关闭返回 null。
///
/// [searchHint] 非空 → 升级为「可搜索单选」下拉(镜像原型 openSearchMenu / .combo-pop
/// 照 stock-out 客户筛选写法):顶部持久搜索框 + 边打边过滤 + 命中高亮 + 底部结果统计,
/// 点选即返回并关闭。宽度锚定触发器宽(`max(触发器宽, 220)` 固定,对齐原型 openSearchMenu
/// `w = max(r.width, 220)`)、列表 264 封顶,供规格/系列等长名录单选筛选复用。
Future<T?> showDsMenu<T>(
BuildContext anchorContext, {
required List<DsMenuItem<T>> items,
double minWidth = 168,
String? searchHint,
}) {
final rect = _anchorRect(anchorContext);
return Navigator.of(anchorContext).push<T>(_DsMenuRoute<T>(
anchorRect: rect,
minWidth: minWidth,
builder: (ctx) => _DsMenuPanel(
children: [
for (final it in items)
_DsMenuItemTile(
item: it,
onTap: () => Navigator.of(ctx).pop(it.value),
minWidth: searchHint != null ? math.max(minWidth, 220) : minWidth,
search: searchHint != null,
builder: (ctx) => searchHint != null
? _DsSearchSinglePanel<T>(
items: items,
hint: searchHint,
onPick: (v) => Navigator.of(ctx).pop(v),
)
: _DsMenuPanel(
children: [
for (final it in items)
_DsMenuItemTile(
item: it,
onTap: () => Navigator.of(ctx).pop(it.value),
),
],
),
],
),
));
}
/// 多选菜单(列设置 / 多选筛选):点击项切换选中、菜单保持打开;点外部关闭。
/// 多选菜单(列设置):点击项切换选中、菜单保持打开;点外部关闭。
/// [itemsBuilder] 每次切换后重建以刷新 ✓ 态;[onToggle] 通知外部改状态。
Future<void> showDsMultiMenu<T>(
BuildContext anchorContext, {
@@ -89,10 +103,12 @@ Rect _anchorRect(BuildContext anchorContext) {
class _DsMenuRoute<T> extends PopupRoute<T> {
final Rect anchorRect;
final double minWidth;
final bool search;
final WidgetBuilder builder;
_DsMenuRoute({
required this.anchorRect,
required this.minWidth,
this.search = false,
required this.builder,
});
@@ -109,7 +125,7 @@ class _DsMenuRoute<T> extends PopupRoute<T> {
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return CustomSingleChildLayout(
delegate: _DsMenuLayout(anchorRect, minWidth),
delegate: _DsMenuLayout(anchorRect, minWidth, search),
child: builder(context),
);
}
@@ -118,15 +134,23 @@ class _DsMenuRoute<T> extends PopupRoute<T> {
class _DsMenuLayout extends SingleChildLayoutDelegate {
final Rect anchor;
final double minWidth;
_DsMenuLayout(this.anchor, this.minWidth);
final bool search;
_DsMenuLayout(this.anchor, this.minWidth, this.search);
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final maxW = math.max(minWidth, constraints.maxWidth - 16);
final avail = constraints.maxWidth - 16;
final maxH = math.max(0.0, constraints.maxHeight - 16);
if (search) {
// 可搜索下拉:宽度锚定触发器宽(对齐原型 openSearchMenu `w = max(r.width, 220)`),
// tight 固定,避免 Expanded 搜索框撑满视口。触发器宽 → 弹层宽;窄 pill → 220。
final w = math.min(math.max(anchor.width, minWidth), avail);
return BoxConstraints(minWidth: w, maxWidth: w, maxHeight: maxH);
}
return BoxConstraints(
minWidth: math.min(math.max(anchor.width, minWidth), maxW),
maxWidth: maxW,
maxHeight: math.max(0, constraints.maxHeight - 16),
minWidth: math.min(math.max(anchor.width, minWidth), avail),
maxWidth: avail,
maxHeight: maxH,
);
}
@@ -144,7 +168,9 @@ class _DsMenuLayout extends SingleChildLayoutDelegate {
@override
bool shouldRelayout(_DsMenuLayout old) =>
old.anchor != anchor || old.minWidth != minWidth;
old.anchor != anchor ||
old.minWidth != minWidth ||
old.search != search;
}
/// .menusurface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。
@@ -224,3 +250,203 @@ class _DsMenuItemTile extends StatelessWidget {
);
}
}
/// 可搜索单选面板(镜像原型 .combo-pop + openSearchMenu,照 stock-out 客户筛选):
/// 顶部 .cp-search 持久搜索框 → .cp-list 列表(命中子串高亮、选中项尾部 ✓、点选即返回并关闭)
/// → .cp-foot 结果统计(匹配 X 条 · 共 N 条)。供工具栏规格/系列等长名录单选筛选复用。
class _DsSearchSinglePanel<T> extends StatefulWidget {
final List<DsMenuItem<T>> items;
final ValueChanged<T> onPick;
final String hint;
const _DsSearchSinglePanel({
required this.items,
required this.onPick,
required this.hint,
});
@override
State<_DsSearchSinglePanel<T>> createState() =>
_DsSearchSinglePanelState<T>();
}
class _DsSearchSinglePanelState<T> extends State<_DsSearchSinglePanel<T>> {
final _ctrl = TextEditingController();
final _focus = FocusNode();
String _kw = '';
@override
void initState() {
super.initState();
WidgetsBinding.instance
.addPostFrameCallback((_) => _focus.requestFocus());
}
@override
void dispose() {
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final all = widget.items;
final q = _kw.trim().toLowerCase();
final hits =
q.isEmpty ? all : all.where((it) => it.label.toLowerCase().contains(q)).toList();
return Material(
color: Colors.transparent,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
boxShadow: [
BoxShadow(
color: t.shadow, offset: const Offset(0, 4), blurRadius: 14),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// .cp-search
Container(
padding: const EdgeInsets.fromLTRB(11, 9, 11, 9),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
Icon(LucideIcons.search, size: 14, color: t.faint),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _ctrl,
focusNode: _focus,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
cursorColor: t.primary,
onChanged: (v) => setState(() => _kw = v),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: widget.hint,
hintStyle: TextStyle(
fontSize: AppDims.fsBody, color: t.faint),
),
),
),
],
),
),
// .cp-listoverflow automax-height:264 → 超出滚动、foot 常驻可见;空 → .cp-empty
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 264),
child: hits.isEmpty
? Padding(
padding: const EdgeInsets.all(20),
child: Text('无匹配结果',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
)
: ListView.builder(
padding: const EdgeInsets.all(5),
shrinkWrap: true,
itemCount: hits.length,
itemBuilder: (c, i) => _item(t, hits[i]),
),
),
// .cp-foot
Container(
padding: const EdgeInsets.fromLTRB(12, 7, 12, 7),
decoration: BoxDecoration(
color: t.bg,
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: _foot(t, hits.length, all.length, q.isNotEmpty),
),
],
),
),
);
}
// .cp-item:点选即回调 onPick(其内 Navigator.pop 关闭菜单)→ 单选。
Widget _item(AppTokens t, DsMenuItem<T> it) {
final fg = it.selected ? t.primary : t.text;
return InkWell(
onTap: () => widget.onPick(it.value),
hoverColor: t.rowHover,
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Expanded(child: _highlight(t, it.label, fg, it.selected)),
if (it.selected) ...[
const SizedBox(width: 9),
Icon(LucideIcons.check, size: 15, color: t.primary),
],
],
),
),
);
}
// 命中子串高亮(.hlprimary fw700
Widget _highlight(AppTokens t, String name, Color fg, bool sel) {
final base = TextStyle(
fontSize: AppDims.fsBody,
color: fg,
fontWeight: sel ? FontWeight.w600 : FontWeight.w400);
final kw = _kw.trim();
final i =
kw.isEmpty ? -1 : name.toLowerCase().indexOf(kw.toLowerCase());
if (i < 0) {
return Text(name,
maxLines: 1, overflow: TextOverflow.ellipsis, style: base);
}
return RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(style: base, children: [
TextSpan(text: name.substring(0, i)),
TextSpan(
text: name.substring(i, i + kw.length),
style: base.copyWith(
color: t.primary, fontWeight: FontWeight.w700)),
TextSpan(text: name.substring(i + kw.length)),
]),
);
}
// .cp-footq 空 → 共 N 条;有关键字 → 匹配 X 条 · 共 N 条。数字 mono 加粗。
Widget _foot(AppTokens t, int hits, int total, bool filtered) {
final base = TextStyle(fontSize: AppDims.fsXs, color: t.muted);
final b = base.copyWith(
color: t.text,
fontWeight: FontWeight.w600,
fontFamily: 'JetBrainsMono');
return RichText(
text: TextSpan(style: base, children: [
if (filtered) ...[
const TextSpan(text: '匹配 '),
TextSpan(text: '$hits', style: b),
const TextSpan(text: ' 条 · 共 '),
TextSpan(text: '$total', style: b),
const TextSpan(text: ''),
] else ...[
const TextSpan(text: ''),
TextSpan(text: '$total', style: b),
const TextSpan(text: ''),
],
]),
);
}
}