Files
jiu/client/lib/widgets/ds/ds_menu.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

227 lines
7.0 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/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。
Future<T?> showDsMenu<T>(
BuildContext anchorContext, {
required List<DsMenuItem<T>> items,
double minWidth = 168,
}) {
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),
),
],
),
));
}
/// 多选菜单(列设置 / 多选筛选):点击项切换选中、菜单保持打开;点外部关闭。
/// [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 WidgetBuilder builder;
_DsMenuRoute({
required this.anchorRect,
required this.minWidth,
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),
child: builder(context),
);
}
}
class _DsMenuLayout extends SingleChildLayoutDelegate {
final Rect anchor;
final double minWidth;
_DsMenuLayout(this.anchor, this.minWidth);
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final maxW = math.max(minWidth, constraints.maxWidth - 16);
return BoxConstraints(
minWidth: math.min(math.max(anchor.width, minWidth), maxW),
maxWidth: maxW,
maxHeight: math.max(0, constraints.maxHeight - 16),
);
}
@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;
}
/// .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)),
],
),
),
);
}
}