fix: 侧边菜单栏拖拽优化 (#7606)

* fix: 拖拽使用遮罩层实现,使得拖拽到min或max临界值时cursor显示not-allowed,同时拖拽线条颜色变浅,类似于disabled,提升用户体验

* fix: 修复代码审查建议;修复lint和test报错

* fix: 修复遮罩层挡住hover:bg-primary视觉效果问题
pull/336/head
zouawen 2026-03-07 05:32:09 +08:00 committed by GitHub
parent 2a86404ba5
commit aa7d8630b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 93 additions and 92 deletions

View File

@ -20,8 +20,8 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"defaultHomePath": "/analytics", "defaultHomePath": "/analytics",
"dynamicTitle": true, "dynamicTitle": true,
"enableCheckUpdates": true, "enableCheckUpdates": true,
"enablePreferences": true,
"enableCopyPreferences": true, "enableCopyPreferences": true,
"enablePreferences": true,
"enableRefreshToken": false, "enableRefreshToken": false,
"enableStickyPreferencesNavigationBar": true, "enableStickyPreferencesNavigationBar": true,
"isMobile": false, "isMobile": false,

View File

@ -53,10 +53,10 @@ interface AppPreferences {
dynamicTitle: boolean; dynamicTitle: boolean;
/** 是否开启检查更新 */ /** 是否开启检查更新 */
enableCheckUpdates: boolean; enableCheckUpdates: boolean;
/** 是否显示偏好设置 */
enablePreferences: boolean;
/** 是否显示复制偏好设置按钮 */ /** 是否显示复制偏好设置按钮 */
enableCopyPreferences: boolean; enableCopyPreferences: boolean;
/** 是否显示偏好设置 */
enablePreferences: boolean;
/** /**
* @zh_CN refreshToken * @zh_CN refreshToken
*/ */

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CSSProperties } from 'vue'; import type { CSSProperties } from 'vue';
import { computed, shallowRef, useSlots, watchEffect } from 'vue'; import { computed, onUnmounted, shallowRef, useSlots, watchEffect } from 'vue';
import { VbenScrollbar } from '@vben-core/shadcn-ui'; import { VbenScrollbar } from '@vben-core/shadcn-ui';
@ -262,20 +262,18 @@ function handleMouseleave() {
extraVisible.value = false; extraVisible.value = false;
} }
const { startDrag } = useSidebarDrag(); const { startDrag, endDrag } = useSidebarDrag();
const handleDragSidebar = (e: MouseEvent) => { const handleDragSidebar = (e: MouseEvent) => {
const { isSidebarMixed, collapseWidth, extraWidth, width } = props; const { isSidebarMixed, collapseWidth, width } = props;
const minLimit = isSidebarMixed ? width + collapseWidth : collapseWidth; const minLimit = isSidebarMixed ? width + collapseWidth : collapseWidth;
const maxLimit = isSidebarMixed ? width + 320 : 320; const maxLimit = isSidebarMixed ? width + 320 : 320;
const startWidth = isSidebarMixed ? width + extraWidth : width;
startDrag( startDrag(
e, e,
{ {
min: minLimit, min: minLimit,
max: maxLimit, max: maxLimit,
startWidth,
}, },
{ {
target: asideRef.value, target: asideRef.value,
@ -293,6 +291,10 @@ const handleDragSidebar = (e: MouseEvent) => {
}, },
); );
}; };
onUnmounted(() => {
endDrag();
});
</script> </script>
<template> <template>

View File

@ -1,9 +1,8 @@
import { onUnmounted } from 'vue'; import { ref } from 'vue';
interface DragOptions { interface DragOptions {
max: number; max: number;
min: number; min: number;
startWidth: number;
} }
interface DragElements { interface DragElements {
@ -14,35 +13,9 @@ interface DragElements {
type DragCallback = (newWidth: number) => void; type DragCallback = (newWidth: number) => void;
export function useSidebarDrag() { export function useSidebarDrag() {
const state: { const isDragging = ref(false);
cleanup: (() => void) | null; let cleanup: (() => void) | null = null;
isDragging: boolean; let dragOverlay: HTMLElement | null = null;
originalStyles: {
bodyCursor: string;
bodyUserSelect: string;
dragBarLeft: string;
dragBarRight: string;
dragBarTransition: string;
targetTransition: string;
};
startLeft: number;
startWidth: number;
startX: number;
} = {
cleanup: null,
isDragging: false,
startLeft: 0,
startWidth: 0,
startX: 0,
originalStyles: {
bodyCursor: '',
bodyUserSelect: '',
dragBarLeft: '',
dragBarRight: '',
dragBarTransition: '',
targetTransition: '',
},
};
const startDrag = ( const startDrag = (
e: MouseEvent, e: MouseEvent,
@ -50,108 +23,130 @@ export function useSidebarDrag() {
elements: DragElements, elements: DragElements,
onDrag: DragCallback, onDrag: DragCallback,
) => { ) => {
const { min, max, startWidth } = options; const { min, max } = options;
const { dragBar, target } = elements; const { dragBar, target } = elements;
if (state.isDragging || !dragBar || !target) return; if (isDragging.value || !dragBar || !target) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
state.isDragging = true; isDragging.value = true;
state.startX = e.clientX; const startX = e.clientX;
state.startWidth = startWidth; const startWidth = target.getBoundingClientRect().width;
state.startLeft = dragBar.offsetLeft; const startLeft = dragBar.offsetLeft;
state.originalStyles = { dragBar.classList.add('bg-primary');
bodyCursor: document.body.style.cursor, dragBar.classList.remove('bg-primary/30');
bodyUserSelect: document.body.style.userSelect,
dragBarLeft: dragBar.style.left,
dragBarRight: dragBar.style.right,
dragBarTransition: dragBar.style.transition,
targetTransition: target.style.transition,
};
document.body.style.cursor = 'col-resize'; const dragBarTransition = dragBar.style.transition;
document.body.style.userSelect = 'none'; const targetTransition = target.style.transition;
dragBar.style.left = `${state.startLeft}px`;
dragBar.style.right = 'auto';
dragBar.style.transition = 'none'; dragBar.style.transition = 'none';
target.style.transition = 'none'; target.style.transition = 'none';
dragOverlay = document.createElement('div');
dragOverlay.style.position = 'fixed';
dragOverlay.style.inset = '0';
dragOverlay.style.zIndex = '9999';
dragOverlay.style.cursor = 'col-resize';
dragOverlay.style.userSelect = 'none';
dragOverlay.style.outline = 'none';
dragOverlay.tabIndex = -1;
dragOverlay.style.background = 'rgba(0,0,0,0)';
document.body.append(dragOverlay);
const onMouseMove = (moveEvent: MouseEvent) => { const onMouseMove = (moveEvent: MouseEvent) => {
if (!state.isDragging || !dragBar) return; if (!isDragging.value || !dragBar || !target) {
endDrag();
return;
}
const deltaX = moveEvent.clientX - state.startX; const deltaX = moveEvent.clientX - startX;
let newLeft = state.startLeft + deltaX; let currentWidth = startWidth + deltaX;
if (newLeft < min) newLeft = min; const isOutOfMin = currentWidth < min;
if (newLeft > max) newLeft = max; const isOutOfMax = currentWidth > max;
const isOutOfBounds = isOutOfMin || isOutOfMax;
if (isOutOfMin) currentWidth = min;
if (isOutOfMax) currentWidth = max;
const newLeft = startLeft + (currentWidth - startWidth);
if (dragOverlay)
dragOverlay.style.cursor = isOutOfBounds ? 'not-allowed' : 'col-resize';
dragBar.style.left = `${newLeft}px`; dragBar.style.left = `${newLeft}px`;
dragBar.classList.add('bg-primary');
if (isOutOfBounds) {
dragBar.classList.add('bg-primary/30');
dragBar.classList.remove('bg-primary');
} else {
dragBar.classList.add('bg-primary');
dragBar.classList.remove('bg-primary/30');
}
}; };
const onMouseUp = (upEvent: MouseEvent) => { const onMouseUp = (upEvent: MouseEvent) => {
if (!state.isDragging || !dragBar || !target) return; if (!isDragging.value || !dragBar || !target) {
endDrag();
return;
}
const deltaX = upEvent.clientX - state.startX; const deltaX = upEvent.clientX - startX;
let newWidth = state.startWidth + deltaX; let newWidth = startWidth + deltaX;
newWidth = Math.min(max, Math.max(min, newWidth)); newWidth = Math.min(max, Math.max(min, newWidth));
dragBar.classList.remove('bg-primary'); dragBar.classList.remove('bg-primary', 'bg-primary/30');
onDrag?.(newWidth); try {
onDrag?.(Math.round(newWidth));
endDrag(); } finally {
endDrag();
}
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp); document.addEventListener('mouseup', onMouseUp);
const cleanup = () => { cleanup = () => {
if (!state.cleanup) return; if (!cleanup) return;
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = state.originalStyles.bodyCursor;
document.body.style.userSelect = state.originalStyles.bodyUserSelect;
if (dragBar) { if (dragBar) {
dragBar.style.left = state.originalStyles.dragBarLeft; dragBar.style.transition = dragBarTransition;
dragBar.style.right = state.originalStyles.dragBarRight; dragBar.style.left = '';
dragBar.style.transition = state.originalStyles.dragBarTransition; dragBar.classList.remove('bg-primary', 'bg-primary/30');
dragBar.classList.remove('bg-primary');
} }
if (target) { if (target) {
target.style.transition = state.originalStyles.targetTransition; target.style.transition = targetTransition;
} }
state.isDragging = false; if (dragOverlay) {
state.cleanup = null; dragOverlay.remove();
}; dragOverlay = null;
}
state.cleanup = cleanup; isDragging.value = false;
cleanup = null;
};
}; };
const endDrag = () => { const endDrag = () => {
state.cleanup?.(); cleanup?.();
}; };
onUnmounted(() => {
endDrag();
});
return { return {
startDrag, startDrag,
endDrag, endDrag,
get isDragging() { get isDragging() {
return state.isDragging; return isDragging.value;
}, },
}; };
} }

View File

@ -15,7 +15,9 @@ const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
const appWatermark = defineModel<boolean>('appWatermark'); const appWatermark = defineModel<boolean>('appWatermark');
const appWatermarkContent = defineModel<string>('appWatermarkContent'); const appWatermarkContent = defineModel<string>('appWatermarkContent');
const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates'); const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
const appEnableCopyPreferences = defineModel<boolean>('appEnableCopyPreferences'); const appEnableCopyPreferences = defineModel<boolean>(
'appEnableCopyPreferences',
);
</script> </script>
<template> <template>

View File

@ -70,7 +70,9 @@ const appContentCompact = defineModel<ContentCompactType>('appContentCompact');
const appWatermark = defineModel<boolean>('appWatermark'); const appWatermark = defineModel<boolean>('appWatermark');
const appWatermarkContent = defineModel<string>('appWatermarkContent'); const appWatermarkContent = defineModel<string>('appWatermarkContent');
const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates'); const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
const appEnableCopyPreferences = defineModel<boolean>('appEnableCopyPreferences'); const appEnableCopyPreferences = defineModel<boolean>(
'appEnableCopyPreferences',
);
const appEnableStickyPreferencesNavigationBar = defineModel<boolean>( const appEnableStickyPreferencesNavigationBar = defineModel<boolean>(
'appEnableStickyPreferencesNavigationBar', 'appEnableStickyPreferencesNavigationBar',
); );