Files
jiu/client/lib/widgets/ds/ds_menu.dart
T
wangjia 801b8528ec 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
2026-08-28 17:30:55 +08:00

453 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// widgets/ds/ds_menu.dart — 原型 .menu/.menu-item 下拉菜单(镜像 atoms.css + shell.js openMenu)。
// 定位规则照抄 openMenu:宽 = max(锚宽, minW=168),左缘夹在视口内(留 8px)
// 锚下 6px 展开、下方放不下且上方够高则向上翻。
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';
/// 菜单项:sel → 前导 ✓(primary) + 文字 primary fw600icon → 前导 16px 图标;
/// 都没有 → 16px 占位(对齐 openMenu 的 lead 槽)。
class DsMenuItem<T> {
final T value;
final String label;
final IconData? icon;
final bool selected;
const DsMenuItem({
required this.value,
required this.label,
this.icon,
this.selected = false,
});
}
/// 单选菜单:点击项返回其 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: 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, {
required List<DsMenuItem<T>> Function() itemsBuilder,
required ValueChanged<T> onToggle,
double minWidth = 168,
}) {
final rect = _anchorRect(anchorContext);
return Navigator.of(anchorContext).push<void>(_DsMenuRoute<void>(
anchorRect: rect,
minWidth: minWidth,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setState) => _DsMenuPanel(
children: [
for (final it in itemsBuilder())
_DsMenuItemTile(
item: it,
onTap: () {
onToggle(it.value);
setState(() {});
},
),
],
),
),
));
}
Rect _anchorRect(BuildContext anchorContext) {
// 锚点矩形必须和菜单路由同坐标系:路由推到 Navigator.of(anchorContext)
// 该导航器不一定占满屏幕(shell 分支导航器原点在侧栏右/顶栏下;
// useRootNavigator:false 的抽屉同理),用全局坐标会整体漂移。
final box = anchorContext.findRenderObject() as RenderBox;
final navBox =
Navigator.of(anchorContext).context.findRenderObject() as RenderBox?;
final origin = box.localToGlobal(Offset.zero, ancestor: navBox);
return origin & box.size;
}
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,
});
@override
Color? get barrierColor => Colors.transparent;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'menu';
@override
Duration get transitionDuration => Duration.zero;
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return CustomSingleChildLayout(
delegate: _DsMenuLayout(anchorRect, minWidth, search),
child: builder(context),
);
}
}
class _DsMenuLayout extends SingleChildLayoutDelegate {
final Rect anchor;
final double minWidth;
final bool search;
_DsMenuLayout(this.anchor, this.minWidth, this.search);
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
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), avail),
maxWidth: avail,
maxHeight: maxH,
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final left = anchor.left
.clamp(8.0, math.max(8.0, size.width - childSize.width - 8))
.toDouble();
final below = anchor.bottom + 6;
final above = anchor.top - childSize.height - 6;
final top =
(below + childSize.height > size.height && above > 6) ? above : below;
return Offset(left, top);
}
@override
bool shouldRelayout(_DsMenuLayout old) =>
old.anchor != anchor ||
old.minWidth != minWidth ||
old.search != search;
}
/// .menusurface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。
class _DsMenuPanel extends StatelessWidget {
final List<Widget> children;
const _DsMenuPanel({required this.children});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(6),
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: SingleChildScrollView(
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
),
);
}
}
/// .menu-itemgap9 / pad 8 10 / r-sm / fs-bodyhover→bg
/// sel → 前导 ✓ + primary fw600icon 颜色随文字(.mi-ic 继承 currentColor)。
class _DsMenuItemTile extends StatelessWidget {
final DsMenuItem<dynamic> item;
final VoidCallback onTap;
const _DsMenuItemTile({required this.item, required this.onTap});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final fg = item.selected ? t.primary : t.text;
return InkWell(
onTap: onTap,
hoverColor: t.bg,
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: item.selected
? Icon(LucideIcons.check, size: 16, color: t.primary)
: (item.icon != null
? Icon(item.icon, size: 16, color: fg)
: null),
),
const SizedBox(width: 9),
Text(item.label,
style: TextStyle(
fontSize: AppDims.fsBody,
color: fg,
fontWeight:
item.selected ? FontWeight.w600 : FontWeight.w400)),
],
),
),
);
}
}
/// 可搜索单选面板(镜像原型 .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: ''),
],
]),
);
}
}