fix(player): 修复顶部语言/余额/头像下拉 hover 间隙导致菜单提前关闭
新增 useHoverDropdown,为下拉菜单增加延迟关闭与透明桥接层,鼠标可从触发按钮顺利移入下拉卡片。
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
42
apps/player/src/composables/useHoverDropdown.ts
Normal file
42
apps/player/src/composables/useHoverDropdown.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user