fix(client): 账号菜单弹按钮正上方不遮挡 + side-user hover 变色

对照原型修两点:
1. 菜单原先 over 盖住 side-user 按钮;改为按「菜单估高」把菜单完整弹到按钮上方
   留 8px 间隙,触发器始终可见(对齐原型)
2. side-user 抽成 _SideUserTile,加 hover 变色(t.sideHover,与左侧 nav 一致)
- user_menu_width_test 增断言:菜单末项底 ≤ 触发器顶(不遮挡)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
wangjia
2026-06-25 18:51:55 +08:00
parent 36ace8a1a0
commit cb549ff0b4
2 changed files with 121 additions and 70 deletions
+114 -70
View File
@@ -84,30 +84,38 @@ class _AppShellState extends ConsumerState<AppShell> {
bool _forceDialogShown = false;
bool _licenseDialogShown = false;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
// 侧栏底部用户区,用于实测宽度让弹出菜单等宽 + 定位(向上弹,不溢出内容区)
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 = <PopupMenuEntry<String>>[
_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<String>(
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<AppShell> {
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<AppShell> {
),
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;
+7
View File
@@ -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<String>).last).bottom;
final triggerTop = tester.getRect(find.textContaining('管理员')).top;
expect(menuBottom, lessThanOrEqualTo(triggerTop),
reason: '菜单应弹在按钮上方,不得盖住 side-user 触发器');
});
}