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

View File

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

View File

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