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 触发器'); }); }