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:
@@ -84,30 +84,38 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
bool _forceDialogShown = false;
|
bool _forceDialogShown = false;
|
||||||
bool _licenseDialogShown = false;
|
bool _licenseDialogShown = false;
|
||||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
// 侧栏底部用户区,用于实测宽度让弹出菜单等宽 + 定位(向上弹,不溢出内容区)
|
|
||||||
final GlobalKey _sideUserKey = GlobalKey();
|
|
||||||
|
|
||||||
/// 打开账号菜单(个人设置 / 退出登录)。菜单宽度 = 用户区实测宽度(自动与侧栏
|
/// 打开账号菜单(个人设置 / 退出登录)。[anchorContext] = 用户区组件的 context,
|
||||||
/// 内容等宽、绝不溢出到内容区);底部空间不足时 showMenu 自动向上弹。
|
/// 据其实测宽度让菜单等宽(不溢出内容区)、据其位置把菜单**完整弹到按钮正上方**
|
||||||
void _openUserMenu(BuildContext context) {
|
/// (留 8px 间隙,不遮挡触发器,对齐原型)。
|
||||||
final t = context.tokens;
|
void _openUserMenu(BuildContext anchorContext) {
|
||||||
final rb = _sideUserKey.currentContext?.findRenderObject() as RenderBox?;
|
final t = anchorContext.tokens;
|
||||||
|
final rb = anchorContext.findRenderObject() as RenderBox?;
|
||||||
final overlay =
|
final overlay =
|
||||||
Overlay.of(context).context.findRenderObject() as RenderBox?;
|
Overlay.of(anchorContext).context.findRenderObject() as RenderBox?;
|
||||||
if (rb == null || overlay == null) return;
|
if (rb == null || overlay == null) return;
|
||||||
final topLeft = rb.localToGlobal(Offset.zero, ancestor: overlay);
|
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>(
|
showMenu<String>(
|
||||||
context: context,
|
context: anchorContext,
|
||||||
position: RelativeRect.fromRect(rect, Offset.zero & overlay.size),
|
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),
|
constraints: BoxConstraints.tightFor(width: rb.size.width),
|
||||||
color: t.menuUserBg,
|
color: t.menuUserBg,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||||
items: [
|
items: items,
|
||||||
_userMenuItem('profile', Icons.manage_accounts_outlined, '个人设置', t),
|
|
||||||
_userMenuItem('logout', Icons.logout, '退出登录', t),
|
|
||||||
],
|
|
||||||
).then((v) {
|
).then((v) {
|
||||||
if (v == 'logout') _logout();
|
if (v == 'logout') _logout();
|
||||||
});
|
});
|
||||||
@@ -302,8 +310,6 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
|
|
||||||
Widget _buildSideUser(BuildContext context, AuthUser user) {
|
Widget _buildSideUser(BuildContext context, AuthUser user) {
|
||||||
final t = context.tokens;
|
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;
|
final shopName = ref.watch(shopInfoProvider).valueOrNull?.name ?? user.shopNo;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -314,59 +320,11 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: GestureDetector(
|
child: _SideUserTile(
|
||||||
behavior: HitTestBehavior.opaque,
|
user: user,
|
||||||
onTap: () => _openUserMenu(context),
|
shopName: shopName,
|
||||||
child: Container(
|
roleLabel: _roleLabel(user.role),
|
||||||
key: _sideUserKey,
|
onTap: _openUserMenu,
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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)───────────────────────────────────────────────
|
// ── 侧栏导航项(原型 .nav, 40h)───────────────────────────────────────────────
|
||||||
class _SidebarNav extends StatefulWidget {
|
class _SidebarNav extends StatefulWidget {
|
||||||
final _NavItem item;
|
final _NavItem item;
|
||||||
|
|||||||
@@ -103,5 +103,12 @@ void main() {
|
|||||||
reason: '菜单应与触发器等宽(~194),不应外扩到 218 而溢出');
|
reason: '菜单应与触发器等宽(~194),不应外扩到 218 而溢出');
|
||||||
expect(itemRect.right, lessThanOrEqualTo(219),
|
expect(itemRect.right, lessThanOrEqualTo(219),
|
||||||
reason: '菜单右缘不得越过侧栏右缘(218)盖住内容区');
|
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 触发器');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user