From cb549ff0b4671697a3137141d70474cdd262ce37 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 25 Jun 2026 18:51:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20=E8=B4=A6=E5=8F=B7=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E5=BC=B9=E6=8C=89=E9=92=AE=E6=AD=A3=E4=B8=8A=E6=96=B9?= =?UTF-8?q?=E4=B8=8D=E9=81=AE=E6=8C=A1=20+=20side-user=20hover=20=E5=8F=98?= =?UTF-8?q?=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照原型修两点: 1. 菜单原先 over 盖住 side-user 按钮;改为按「菜单估高」把菜单完整弹到按钮上方 留 8px 间隙,触发器始终可见(对齐原型) 2. side-user 抽成 _SideUserTile,加 hover 变色(t.sideHover,与左侧 nav 一致) - user_menu_width_test 增断言:菜单末项底 ≤ 触发器顶(不遮挡) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX --- client/lib/screens/shell/app_shell.dart | 184 +++++++++++++++--------- client/test/user_menu_width_test.dart | 7 + 2 files changed, 121 insertions(+), 70 deletions(-) diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 9300bc2..69c4b30 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -84,30 +84,38 @@ class _AppShellState extends ConsumerState { bool _forceDialogShown = false; bool _licenseDialogShown = false; final GlobalKey _scaffoldKey = GlobalKey(); - // 侧栏底部用户区,用于实测宽度让弹出菜单等宽 + 定位(向上弹,不溢出内容区) - final GlobalKey _sideUserKey = GlobalKey(); - /// 打开账号菜单(个人设置 / 退出登录)。菜单宽度 = 用户区实测宽度(自动与侧栏 - /// 内容等宽、绝不溢出到内容区);底部空间不足时 showMenu 自动向上弹。 - void _openUserMenu(BuildContext context) { - final t = context.tokens; - final rb = _sideUserKey.currentContext?.findRenderObject() as RenderBox?; + /// 打开账号菜单(个人设置 / 退出登录)。[anchorContext] = 用户区组件的 context, + /// 据其实测宽度让菜单等宽(不溢出内容区)、据其位置把菜单**完整弹到按钮正上方** + /// (留 8px 间隙,不遮挡触发器,对齐原型)。 + void _openUserMenu(BuildContext anchorContext) { + final t = anchorContext.tokens; + final rb = anchorContext.findRenderObject() as RenderBox?; final overlay = - Overlay.of(context).context.findRenderObject() as RenderBox?; + Overlay.of(anchorContext).context.findRenderObject() as RenderBox?; if (rb == null || overlay == null) return; final topLeft = rb.localToGlobal(Offset.zero, ancestor: overlay); - final rect = topLeft & rb.size; + final items = >[ + _userMenuItem('profile', Icons.manage_accounts_outlined, '个人设置', t), + _userMenuItem('logout', Icons.logout, '退出登录', t), + ]; + // 菜单弹到按钮上方:top = 按钮顶 − 菜单估高 − 间隙(菜单项 40 + 上下 padding 8*2) + const itemH = 40.0; + final menuH = items.length * itemH + 16; + final top = topLeft.dy - menuH - 8; showMenu( - context: context, - position: RelativeRect.fromRect(rect, Offset.zero & overlay.size), + context: anchorContext, + position: RelativeRect.fromLTRB( + topLeft.dx, + top, + overlay.size.width - topLeft.dx - rb.size.width, + overlay.size.height - top, + ), constraints: BoxConstraints.tightFor(width: rb.size.width), color: t.menuUserBg, elevation: 8, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), - items: [ - _userMenuItem('profile', Icons.manage_accounts_outlined, '个人设置', t), - _userMenuItem('logout', Icons.logout, '退出登录', t), - ], + items: items, ).then((v) { if (v == 'logout') _logout(); }); @@ -302,8 +310,6 @@ class _AppShellState extends ConsumerState { Widget _buildSideUser(BuildContext context, AuthUser user) { final t = context.tokens; - final initial = (user.realName.isNotEmpty ? user.realName : user.username); - final pic = initial.isNotEmpty ? initial.characters.first : '用'; final shopName = ref.watch(shopInfoProvider).valueOrNull?.name ?? user.shopNo; return Column( children: [ @@ -314,59 +320,11 @@ class _AppShellState extends ConsumerState { ), Padding( padding: const EdgeInsets.only(bottom: 8), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _openUserMenu(context), - child: Container( - key: _sideUserKey, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - ), - child: Row( - children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: t.sideActiveBg, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text(pic, - style: TextStyle( - color: t.sideActiveFg, - fontWeight: FontWeight.w700, - fontSize: 14)), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - user.realName.isNotEmpty - ? user.realName - : user.username, - style: TextStyle( - color: t.sideActiveFg, - fontSize: 13, - fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - Text( - '$shopName · ${_roleLabel(user.role)}', - style: TextStyle(color: t.sideMuted, fontSize: 11), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon(Icons.keyboard_arrow_down, size: 14, color: t.sideMuted), - ], - ), - ), + child: _SideUserTile( + user: user, + shopName: shopName, + roleLabel: _roleLabel(user.role), + onTap: _openUserMenu, ), ), ], @@ -788,6 +746,92 @@ class _ShopMark extends StatelessWidget { } } +// ── 侧栏底部用户区(原型 .side-user):hover 变色(同 nav 的 side-hover),点开账号菜单 ── +class _SideUserTile extends StatefulWidget { + final AuthUser user; + final String shopName; + final String roleLabel; + final void Function(BuildContext anchorContext) onTap; + const _SideUserTile({ + required this.user, + required this.shopName, + required this.roleLabel, + required this.onTap, + }); + + @override + State<_SideUserTile> createState() => _SideUserTileState(); +} + +class _SideUserTileState extends State<_SideUserTile> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final u = widget.user; + final initial = u.realName.isNotEmpty ? u.realName : u.username; + final pic = initial.isNotEmpty ? initial.characters.first : '用'; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onTap(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: _hover ? t.sideHover : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: t.sideActiveBg, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text(pic, + style: TextStyle( + color: t.sideActiveFg, + fontWeight: FontWeight.w700, + fontSize: 14)), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + u.realName.isNotEmpty ? u.realName : u.username, + style: TextStyle( + color: t.sideActiveFg, + fontSize: 13, + fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + Text( + '${widget.shopName} · ${widget.roleLabel}', + style: TextStyle(color: t.sideMuted, fontSize: 11), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon(Icons.keyboard_arrow_down, size: 14, color: t.sideMuted), + ], + ), + ), + ), + ); + } +} + // ── 侧栏导航项(原型 .nav, 40h)─────────────────────────────────────────────── class _SidebarNav extends StatefulWidget { final _NavItem item; diff --git a/client/test/user_menu_width_test.dart b/client/test/user_menu_width_test.dart index 2c4dcb7..f37fedd 100644 --- a/client/test/user_menu_width_test.dart +++ b/client/test/user_menu_width_test.dart @@ -103,5 +103,12 @@ void main() { reason: '菜单应与触发器等宽(~194),不应外扩到 218 而溢出'); expect(itemRect.right, lessThanOrEqualTo(219), reason: '菜单右缘不得越过侧栏右缘(218)盖住内容区'); + + // 菜单整体在触发器上方,不遮挡 side-user 按钮(菜单末项底 ≤ 触发器文本顶) + final menuBottom = + tester.getRect(find.byType(PopupMenuItem).last).bottom; + final triggerTop = tester.getRect(find.textContaining('管理员')).top; + expect(menuBottom, lessThanOrEqualTo(triggerTop), + reason: '菜单应弹在按钮上方,不得盖住 side-user 触发器'); }); }