fix(player): 修复顶部语言/余额/头像下拉 hover 间隙导致菜单提前关闭

新增 useHoverDropdown,为下拉菜单增加延迟关闭与透明桥接层,鼠标可从触发按钮顺利移入下拉卡片。
This commit is contained in:
2026-07-02 11:49:16 +08:00
parent 8000c23418
commit 3bb4dd9b23
4 changed files with 98 additions and 18 deletions

View File

@@ -4,11 +4,12 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatMoney } from '../utils/localeDisplay';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useHoverDropdown } from '../composables/useHoverDropdown';
const { locale, t } = useI18n();
const { profileRaw, refreshProfile } = usePlayerProfile();
const router = useRouter();
const open = ref(false);
const { open, openHover, scheduleClose, closeNow } = useHoverDropdown();
const root = ref<HTMLElement | null>(null);
const wallet = computed(() => profileRaw.value?.wallet ?? null);
@@ -44,7 +45,12 @@ function toggle() {
}
function close() {
open.value = false;
closeNow();
}
function onMouseEnter() {
openHover();
void refreshProfile();
}
function goRecharge() {
@@ -53,7 +59,7 @@ function goRecharge() {
}
function onOutsideClick(e: Event) {
if (!root.value?.contains(e.target as Node)) open.value = false;
if (!root.value?.contains(e.target as Node)) closeNow();
}
onMounted(() => {
@@ -71,8 +77,8 @@ onUnmounted(() => {
<div
ref="root"
class="cash-chip-wrap"
@mouseenter="open = true; refreshProfile()"
@mouseleave="open = false"
@mouseenter="onMouseEnter"
@mouseleave="scheduleClose"
>
<button type="button" class="cash-chip" @click="toggle">
<span class="chip-body">
@@ -114,6 +120,16 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 6px;
--dropdown-gap: 8px;
}
.cash-chip-wrap::after {
content: '';
position: absolute;
top: 100%;
left: 0;
right: 0;
height: var(--dropdown-gap);
}
.direct-recharge-btn {
@@ -193,7 +209,7 @@ onUnmounted(() => {
.cash-panel {
position: absolute;
top: calc(100% + 8px);
top: calc(100% + var(--dropdown-gap));
right: 0;
width: min(260px, 78vw);
padding: 12px 14px;

View File

@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import LocaleFlag from './LocaleFlag.vue';
import { useAppLocale } from '../composables/useAppLocale';
import { useHoverDropdown } from '../composables/useHoverDropdown';
withDefaults(
defineProps<{
@@ -14,7 +15,7 @@ withDefaults(
const { locale } = useI18n();
const { locales, setLocale } = useAppLocale();
const open = ref(false);
const { open, openHover, scheduleClose, closeNow } = useHoverDropdown();
const root = ref<HTMLElement | null>(null);
const currentLabel = computed(
@@ -22,7 +23,7 @@ const currentLabel = computed(
);
async function pick(code: string) {
open.value = false;
closeNow();
await setLocale(code);
}
@@ -31,7 +32,7 @@ function toggle() {
}
function onOutsideClick(e: Event) {
if (!root.value?.contains(e.target as Node)) open.value = false;
if (!root.value?.contains(e.target as Node)) closeNow();
}
onMounted(() => {
@@ -49,8 +50,8 @@ onUnmounted(() => {
ref="root"
class="locale-switch"
:class="{ compact, open }"
@mouseenter="open = true"
@mouseleave="open = false"
@mouseenter="openHover"
@mouseleave="scheduleClose"
>
<button type="button" class="locale-trigger" :aria-expanded="open" aria-haspopup="listbox" @click.stop="toggle">
<LocaleFlag :locale="locale" :size="compact ? 16 : 18" />
@@ -77,6 +78,16 @@ onUnmounted(() => {
z-index: 120;
display: inline-flex;
flex-shrink: 0;
--dropdown-gap: 4px;
}
.locale-switch::after {
content: '';
position: absolute;
top: 100%;
left: 0;
right: 0;
height: var(--dropdown-gap);
}
.locale-trigger {
@@ -105,7 +116,7 @@ onUnmounted(() => {
.locale-menu {
position: absolute;
top: calc(100% + 4px);
top: calc(100% + var(--dropdown-gap));
right: 0;
z-index: 130;
min-width: 100%;

View File

@@ -5,12 +5,13 @@ import { useI18n } from 'vue-i18n';
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
import { useAuthStore } from '../stores/auth';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useHoverDropdown } from '../composables/useHoverDropdown';
const { t } = useI18n();
const auth = useAuthStore();
const router = useRouter();
const { avatarUrl } = usePlayerProfile();
const open = ref(false);
const { open, openHover, scheduleClose, closeNow } = useHoverDropdown();
const root = ref<HTMLElement | null>(null);
const displayAvatarUrl = computed(() => {
@@ -24,11 +25,11 @@ function toggle() {
}
function close() {
open.value = false;
closeNow();
}
function onOutsideClick(e: Event) {
if (!root.value?.contains(e.target as Node)) open.value = false;
if (!root.value?.contains(e.target as Node)) closeNow();
}
onMounted(() => {
@@ -61,8 +62,8 @@ function logout() {
<div
ref="root"
class="avatar-wrap"
@mouseenter="open = true"
@mouseleave="open = false"
@mouseenter="openHover"
@mouseleave="scheduleClose"
>
<button type="button" class="avatar-btn" :aria-expanded="open" @click="toggle">
<img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="avatar-img" />
@@ -81,6 +82,16 @@ function logout() {
.avatar-wrap {
position: relative;
z-index: 130;
--dropdown-gap: 8px;
}
.avatar-wrap::after {
content: '';
position: absolute;
top: 100%;
left: 0;
right: 0;
height: var(--dropdown-gap);
}
.avatar-btn {
@@ -114,7 +125,7 @@ function logout() {
.avatar-menu {
position: absolute;
top: calc(100% + 8px);
top: calc(100% + var(--dropdown-gap));
right: 0;
min-width: 168px;
padding: 8px 0;

View File

@@ -0,0 +1,42 @@
import { ref, onUnmounted } from 'vue';
export function useHoverDropdown(delay = 200) {
const open = ref(false);
let closeTimer: number | undefined;
function cancelClose() {
if (closeTimer !== undefined) {
window.clearTimeout(closeTimer);
closeTimer = undefined;
}
}
function openHover() {
cancelClose();
open.value = true;
}
function scheduleClose() {
cancelClose();
closeTimer = window.setTimeout(() => {
open.value = false;
closeTimer = undefined;
}, delay);
}
function closeNow() {
cancelClose();
open.value = false;
}
onUnmounted(() => {
cancelClose();
});
return {
open,
openHover,
scheduleClose,
closeNow,
};
}