feat: 移动端适配(响应式优化)

This commit is contained in:
zhangjf 2026-02-16 10:17:01 +08:00
parent c7904a9f8b
commit 0eae7f2aaf
3 changed files with 683 additions and 0 deletions

View File

@ -0,0 +1,178 @@
<template>
<div class="mobile-sidebar-wrapper">
<!-- 遮罩层 -->
<div
class="mobile-sidebar-mask"
:class="{ show: modelValue }"
@click="handleClose"
/>
<!-- 侧边栏 -->
<div
class="mobile-sidebar"
:class="{ open: modelValue }"
>
<div class="mobile-sidebar-header">
<div class="logo">
<img src="@/assets/logo.png" alt="logo" v-if="false">
<span>资金服务平台</span>
</div>
<el-button link @click="handleClose">
<el-icon><Close /></el-icon>
</el-button>
</div>
<div class="mobile-sidebar-content">
<el-menu
:default-active="activeMenu"
:collapse="false"
:collapse-transition="false"
router
>
<sidebar-item
v-for="route in routes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Close } from '@element-plus/icons-vue'
import SidebarItem from '@/layout/components/Sidebar/SidebarItem.vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const route = useRoute()
//
const activeMenu = computed(() => {
const { meta, path } = route
if (meta.activeMenu) {
return meta.activeMenu
}
return path
})
//
const routes = computed(() => {
//
const allRoutes = route.matched[0]?.children || []
return allRoutes.filter(item => !item.hidden)
})
//
const handleClose = () => {
emit('update:modelValue', false)
}
</script>
<style scoped lang="scss">
.mobile-sidebar-wrapper {
display: none;
@media screen and (max-width: 768px) {
display: block;
}
}
.mobile-sidebar-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
&.show {
opacity: 1;
visibility: visible;
}
}
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 260px;
background: #304156;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s;
display: flex;
flex-direction: column;
&.open {
transform: translateX(0);
}
&-header {
height: 50px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
background: #2b3a4d;
.logo {
color: #fff;
font-size: 16px;
font-weight: 600;
img {
width: 32px;
height: 32px;
margin-right: 10px;
vertical-align: middle;
}
}
.el-button {
color: #fff;
font-size: 20px;
}
}
&-content {
flex: 1;
overflow-y: auto;
:deep(.el-menu) {
border-right: none;
background: transparent;
.el-menu-item,
.el-sub-menu__title {
color: #bfcbd9;
&:hover {
background: #263445;
}
&.is-active {
color: #409eff;
background: #263445;
}
}
}
}
}
</style>

View File

@ -0,0 +1,209 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
// 断点定义
const BREAKPOINTS = {
xs: 768, // 手机
sm: 992, // 平板
md: 1200, // 小桌面
lg: 1920 // 大桌面
}
/**
* 移动端检测组合式函数
*/
export function useMobile() {
// 视口宽度
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
// 是否移动端(< 768px
const isMobile = computed(() => width.value < BREAKPOINTS.xs)
// 是否平板768px - 992px
const isTablet = computed(() => width.value >= BREAKPOINTS.xs && width.value < BREAKPOINTS.sm)
// 是否桌面(>= 992px
const isDesktop = computed(() => width.value >= BREAKPOINTS.sm)
// 是否小屏幕(< 992px
const isSmallScreen = computed(() => width.value < BREAKPOINTS.sm)
// 当前断点
const breakpoint = computed(() => {
if (width.value < BREAKPOINTS.xs) return 'xs'
if (width.value < BREAKPOINTS.sm) return 'sm'
if (width.value < BREAKPOINTS.md) return 'md'
if (width.value < BREAKPOINTS.lg) return 'lg'
return 'xl'
})
// 侧边栏是否折叠
const sidebarCollapsed = ref(false)
// 移动端侧边栏是否打开
const mobileSidebarOpen = ref(false)
// 切换侧边栏
const toggleSidebar = () => {
if (isMobile.value) {
mobileSidebarOpen.value = !mobileSidebarOpen.value
} else {
sidebarCollapsed.value = !sidebarCollapsed.value
}
}
// 关闭移动端侧边栏
const closeMobileSidebar = () => {
mobileSidebarOpen.value = false
}
// 打开移动端侧边栏
const openMobileSidebar = () => {
mobileSidebarOpen.value = true
}
// 监听窗口大小变化
const handleResize = () => {
width.value = window.innerWidth
height.value = window.innerHeight
// 切换到桌面端时关闭移动端侧边栏
if (!isMobile.value) {
mobileSidebarOpen.value = false
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
handleResize()
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
return {
width,
height,
isMobile,
isTablet,
isDesktop,
isSmallScreen,
breakpoint,
sidebarCollapsed,
mobileSidebarOpen,
toggleSidebar,
closeMobileSidebar,
openMobileSidebar
}
}
/**
* 响应式表格组合式函数
*/
export function useResponsiveTable() {
const { isMobile } = useMobile()
// 表格高度
const tableHeight = computed(() => {
if (isMobile.value) {
return 'auto'
}
// 桌面端:视口高度 - 头部(50) - 搜索区域(80) - 分页(50) - 边距(40)
return window.innerHeight - 220
})
// 分页大小选项
const pageSizeOptions = computed(() => {
return isMobile.value ? [10, 20] : [10, 20, 50, 100]
})
// 默认分页大小
const defaultPageSize = computed(() => {
return isMobile.value ? 10 : 20
})
return {
isMobile,
tableHeight,
pageSizeOptions,
defaultPageSize
}
}
/**
* 触摸手势支持
*/
export function useTouchGesture() {
const touchStartX = ref(0)
const touchEndX = ref(0)
const touchStartY = ref(0)
const touchEndY = ref(0)
// 最小滑动距离
const MIN_SWIPE_DISTANCE = 50
// 处理触摸开始
const handleTouchStart = (e) => {
touchStartX.value = e.touches[0].clientX
touchStartY.value = e.touches[0].clientY
}
// 处理触摸结束
const handleTouchEnd = (e) => {
touchEndX.value = e.changedTouches[0].clientX
touchEndY.value = e.changedTouches[0].clientY
return {
direction: getSwipeDirection(),
distance: Math.abs(touchEndX.value - touchStartX.value)
}
}
// 获取滑动方向
const getSwipeDirection = () => {
const diffX = touchEndX.value - touchStartX.value
const diffY = touchEndY.value - touchStartY.value
// 判断是水平滑动还是垂直滑动
if (Math.abs(diffX) > Math.abs(diffY)) {
if (Math.abs(diffX) < MIN_SWIPE_DISTANCE) return null
return diffX > 0 ? 'right' : 'left'
} else {
if (Math.abs(diffY) < MIN_SWIPE_DISTANCE) return null
return diffY > 0 ? 'down' : 'up'
}
}
return {
handleTouchStart,
handleTouchEnd,
getSwipeDirection
}
}
/**
* 虚拟键盘检测
*/
export function useVirtualKeyboard() {
const isKeyboardOpen = ref(false)
const windowHeight = ref(window.innerHeight)
const handleResize = () => {
const currentHeight = window.innerHeight
// 如果窗口高度变小超过150px认为是虚拟键盘打开
isKeyboardOpen.value = windowHeight.value - currentHeight > 150
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
return {
isKeyboardOpen
}
}

View File

@ -0,0 +1,296 @@
// 移动端适配样式
// 断点定义xs < 768px < sm < 992px < md < 1200px < lg
// 移动端基础样式
@media screen and (max-width: 768px) {
// 隐藏侧边栏
.sidebar-container {
transform: translateX(-100%);
transition: transform 0.3s;
&.mobile-open {
transform: translateX(0);
}
}
// 主内容区域全宽
.main-container {
margin-left: 0 !important;
width: 100% !important;
}
// 顶部导航栏适配
.navbar {
.hamburger-container {
display: none;
}
.breadcrumb-container {
display: none;
}
.right-menu {
.right-menu-item {
padding: 0 8px;
}
}
}
// 搜索表单改为垂直布局
.search-form {
.el-form-item {
margin-right: 0;
margin-bottom: 10px;
width: 100%;
.el-input,
.el-select {
width: 100% !important;
}
}
.el-button {
width: 100%;
margin-left: 0 !important;
margin-bottom: 10px;
}
}
// 表格操作按钮
.el-table {
.el-button--link {
padding: 4px 8px;
font-size: 12px;
}
}
// 分页器适配
.el-pagination {
.el-pagination__sizes,
.el-pagination__jump {
display: none;
}
.btn-prev,
.btn-next {
min-width: 28px;
}
.el-pager li {
min-width: 28px;
}
}
// 对话框全屏
.el-dialog {
width: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
height: 100vh;
.el-dialog__body {
max-height: calc(100vh - 120px);
overflow-y: auto;
}
}
// 抽屉全屏
.el-drawer {
width: 100% !important;
}
// 表单单行显示
.el-form {
.el-form-item {
margin-bottom: 15px;
.el-form-item__label {
float: none;
display: block;
text-align: left;
margin-bottom: 5px;
}
.el-form-item__content {
margin-left: 0 !important;
}
}
}
// 卡片内边距减小
.el-card {
.el-card__header {
padding: 12px 15px;
}
.el-card__body {
padding: 15px;
}
}
// 统计卡片
.dashboard-card {
margin-bottom: 15px;
.card-icon {
font-size: 36px;
}
.card-value {
font-size: 20px;
}
}
// 底部固定按钮
.fixed-bottom-buttons {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 10px 15px;
background: #fff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 100;
display: flex;
gap: 10px;
.el-button {
flex: 1;
}
}
// 底部留白避免被固定按钮遮挡
.has-fixed-bottom {
padding-bottom: 60px;
}
}
// 平板适配
@media screen and (min-width: 769px) and (max-width: 992px) {
.sidebar-container {
width: 54px !important;
.el-menu {
.el-sub-menu__title,
.el-menu-item {
padding: 0 15px !important;
span {
display: none;
}
.el-sub-menu__icon-arrow {
display: none;
}
}
}
}
.main-container {
margin-left: 54px !important;
}
}
// 移动端菜单按钮
.mobile-menu-btn {
display: none;
@media screen and (max-width: 768px) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
font-size: 20px;
cursor: pointer;
}
}
// 移动端遮罩层
.mobile-sidebar-mask {
display: none;
@media screen and (max-width: 768px) {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
&.show {
opacity: 1;
visibility: visible;
}
}
}
// 响应式工具类
.hide-on-mobile {
@media screen and (max-width: 768px) {
display: none !important;
}
}
.show-on-mobile {
display: none !important;
@media screen and (max-width: 768px) {
display: block !important;
}
}
// 响应式表格
.responsive-table {
@media screen and (max-width: 768px) {
.el-table {
&__header-wrapper {
display: none;
}
&__body-wrapper {
.el-table__row {
display: block;
margin-bottom: 15px;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 10px;
td {
display: flex;
justify-content: space-between;
align-items: center;
border: none;
padding: 8px 0;
&:not(:last-child) {
border-bottom: 1px solid #ebeef5;
}
&::before {
content: attr(data-label);
font-weight: bold;
color: #606266;
}
}
}
}
}
}
}
// 触摸优化
@media (hover: none) {
.el-button:hover {
opacity: 1;
}
.el-table__row:hover > td {
background-color: transparent !important;
}
}