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 _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;
|
||||
|
||||
@@ -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 触发器');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user