【底座】平板适配更舒服

This commit is contained in:
俞宝山
2026-08-26 20:50:25 +08:00
parent d1afd0e8fe
commit 9b6ffc8376
8 changed files with 254 additions and 43 deletions
@@ -3,7 +3,7 @@
v-if="isModal"
v-bind="attrs"
:open="isOpen"
:width="modalWidth"
:width="calcModalWidth"
:footer="slots.footer ? undefined : null"
:wrap-class-name="`xn-form-modal ${wrapClassName} ${fullscreenClass}`"
:bodyStyle="calcBodyStyle"
@@ -238,6 +238,22 @@
// Tooltip 挂载 body, 防止拖拽后定位错位
const getTooltipContainer = () => document.body
/************************* 尺寸钳制 *************************/
// 视口宽高,用于把业务设定的固定尺寸钳制在屏幕范围内
const viewportWidth = ref(window.innerWidth)
const viewportHeight = ref(window.innerHeight)
// 把尺寸解析为像素数值,非固定像素(百分比、vw、calc 等)返回 null 不参与钳制
const parsePxSize = (size) => {
if (typeof size === 'number') {
return Number.isFinite(size) ? size : null
}
if (typeof size === 'string') {
const matched = /^\s*(\d+(\.\d+)?)\s*(px)?\s*$/.exec(size)
return matched ? Number(matched[1]) : null
}
return null
}
/************************* 对话框 *************************/
// 响应式对话框
const modalContentRef = ref()
@@ -245,6 +261,15 @@
// 响应式对话框宽度
const modalWidth = ref(props.width)
// 对话框最终宽度:业务设定的宽度超过视口时收敛为视口宽度,避免弹窗溢出布局
const calcModalWidth = computed(() => {
const pxWidth = parsePxSize(modalWidth.value)
if (pxWidth !== null && pxWidth > viewportWidth.value) {
return viewportWidth.value
}
return modalWidth.value
})
// 响应式对话框高度
const modalHeight = ref('auto')
@@ -427,12 +452,22 @@
// 抽屉宽度
const drawerWidth = computed(() => {
if (isSmallScreen.value || drawerFullscreen.value) return '100%'
// 业务设定的宽度超过视口时占满整屏,避免抽屉溢出布局
const pxWidth = parsePxSize(baseDrawerWidth.value)
if (pxWidth !== null && pxWidth > viewportWidth.value) {
return '100%'
}
return baseDrawerWidth.value
})
// 抽屉高度
const drawerHeight = computed(() => {
if (isSmallScreen.value || drawerFullscreen.value) return '100%'
// 业务设定的高度超过视口时占满整屏(上下方向抽屉)
const pxHeight = parsePxSize(baseDrawerHeight.value)
if (pxHeight !== null && pxHeight > viewportHeight.value) {
return '100%'
}
return baseDrawerHeight.value
})
@@ -544,6 +579,8 @@
// 监听窗口大小变化
const handleResize = () => {
isSmallScreen.value = window.innerWidth <= 768
viewportWidth.value = window.innerWidth
viewportHeight.value = window.innerHeight
}
onMounted(() => {
@@ -25,8 +25,12 @@
const isMobile = computed(() => {
return store.isMobile
})
const layoutTags = computed(() => {
return store.layoutTags
const layoutTagsOpen = computed(() => {
return store.layoutTagsOpen
})
// 窄屏或未开启多标签时多标签栏不显示,此时内嵌页只保留当前一个,其余情况随多标签共存
const iframeSingleMode = computed(() => {
return isMobile.value || !layoutTagsOpen.value
})
watch(route, () => {
@@ -41,12 +45,12 @@
const clearIframeList = iStore.clearIframeList
const push = (route) => {
if (route.meta.type === 'iframe') {
if (isMobile || !layoutTags) {
if (iframeSingleMode.value) {
setIframeList(route)
} else {
pushIframeList(route)
}
} else if (isMobile || !layoutTags) {
} else if (iframeSingleMode.value) {
clearIframeList()
}
}
@@ -1,5 +1,6 @@
<template>
<div class="layout-items-center" v-if="moduleUnfoldOpen && layout !== layoutEnum.TOP">
<!-- 平板尺寸下不展开模块坞,统一走下方紧凑的模块切换风格,避免顶栏被撑爆 -->
<div class="layout-items-center" v-if="moduleUnfoldOpen && layout !== layoutEnum.TOP && !isTablet">
<a-menu
v-model:selectedKeys="selectedKeys"
mode="horizontal"
@@ -22,7 +23,7 @@
</a-menu>
</div>
<div v-else>
<a-popover v-if="menu.length > 1 && !isMobile" placement="bottomLeft">
<a-popover v-if="menu.length > 1 && !isPhone" placement="bottomLeft">
<template #content>
<a-row :gutter="[0, 5]" class="module-row">
<div v-for="item in menu" :key="item.id">
@@ -64,6 +65,14 @@
const isMobile = computed(() => {
return store.isMobile
})
// 手机尺寸(小于 768px)
const isPhone = computed(() => {
return store.isPhone
})
// 平板尺寸(768px ~ 991px)
const isTablet = computed(() => {
return store.isMobile && !store.isPhone
})
const themeColor = computed(() => {
return store.themeColor
})
+44 -16
View File
@@ -4,6 +4,8 @@
v-if="layout === layoutEnum.CLASSICAL"
:layout="layout"
:isMobile="isMobile"
:isPhone="isPhone"
:isTablet="isTablet"
:menuIsCollapse="menuIsCollapse"
:sideTheme="sideTheme"
:sysBaseConfig="sysBaseConfig"
@@ -26,6 +28,8 @@
v-else-if="layout === layoutEnum.DOUBLEROW"
:layout="layout"
:isMobile="isMobile"
:isPhone="isPhone"
:isTablet="isTablet"
:sideTheme="sideTheme"
:secondMenuSideTheme="secondMenuSideTheme"
:sysBaseConfig="sysBaseConfig"
@@ -60,6 +64,8 @@
:footerCopyrightOpen="footerCopyrightOpen"
:sideTheme="sideTheme"
:isMobile="isMobile"
:isPhone="isPhone"
:isTablet="isTablet"
:kStore="kStore"
:layoutTagsOpen="layoutTagsOpen"
@switchModule="switchModule"
@@ -117,6 +123,14 @@
const isMobile = computed(() => {
return store.isMobile
})
// 手机尺寸(小于 768px)
const isPhone = computed(() => {
return store.isPhone
})
// 平板尺寸(768px ~ 991px),介于手机与桌面之间
const isTablet = computed(() => {
return store.isMobile && !store.isPhone
})
const menuIsCollapse = computed(() => {
return store.menuIsCollapse
})
@@ -330,9 +344,15 @@
const menuNavList = menu.value
menuList.value = menuNavList
nextTick(() => {
const topHeaderMenuDom = document.querySelector('#topHeaderMenu')
const lineNavDom = document.querySelector('#xn-line-nav')
// 手机尺寸下顶栏横向菜单不渲染,无需计算折叠数量
if (!topHeaderMenuDom || !lineNavDom) {
return
}
// 获取所有的导航菜单
let liArr = document.querySelector('#topHeaderMenu').querySelectorAll('li')
let allWidth = document.querySelector('#xn-line-nav').offsetWidth // 可以显示区域的宽度
let liArr = topHeaderMenuDom.querySelectorAll('li')
let allWidth = lineNavDom.offsetWidth // 可以显示区域的宽度
// 计算显示的宽度
let num = 0
@@ -413,6 +433,13 @@
}
})
})
// 手机与非手机尺寸互相切换时,顶栏横向菜单会销毁或重建,需要重算顶栏样式与折叠数量
watch(isPhone, () => {
nextTick(() => {
settingTopHeaderThemeOrColor(theme.value, layout.value)
getNav()
})
})
watch(topHeaderThemeColorOpen, () => {
switchoverTopHeaderThemeColor()
})
@@ -439,11 +466,11 @@
const topHeaderMenu = document.getElementById('topHeaderMenu')
if (layout.value === layoutEnum.TOP) {
if (newValue) {
header.classList.add('top-snowy-header-layout')
topHeaderMenu.classList.add('top-snowy-header-layout')
header?.classList.add('top-snowy-header-layout')
topHeaderMenu?.classList.add('top-snowy-header-layout')
} else {
header.classList.remove('top-snowy-header-layout')
topHeaderMenu.classList.remove('top-snowy-header-layout')
header?.classList.remove('top-snowy-header-layout')
topHeaderMenu?.classList.remove('top-snowy-header-layout')
}
}
})
@@ -475,27 +502,27 @@
if (topHeaderThemeColorOpen.value && layout === layoutEnum.TOP) {
nextTick(() => {
topHeaderMenu.classList.add('top-snowy-header-layout')
header.classList.add('top-snowy-header-layout')
topHeaderMenu?.classList.add('top-snowy-header-layout')
header?.classList.add('top-snowy-header-layout')
})
} else if (!topHeaderThemeColorOpen.value && layout === layoutEnum.TOP) {
nextTick(() => {
topHeaderMenu.classList.remove('top-snowy-header-layout')
header.classList.remove('top-snowy-header-layout')
topHeaderMenu?.classList.remove('top-snowy-header-layout')
header?.classList.remove('top-snowy-header-layout')
})
}
if (theme === themeEnum.LIGHT && layout === layoutEnum.TOP) {
header.classList.remove('top-snowy-header')
header.classList.add('top-snowy-header-light')
header?.classList.remove('top-snowy-header')
header?.classList.add('top-snowy-header-light')
} else {
header.classList.remove('top-snowy-header-light')
header?.classList.remove('top-snowy-header-light')
if (layout === layoutEnum.TOP) {
header.classList.add('top-snowy-header')
header?.classList.add('top-snowy-header')
} else {
if (theme === themeEnum.REAL_DARK) {
header.classList.add('top-snowy-header')
header?.classList.add('top-snowy-header')
} else {
header.classList.remove('top-snowy-header')
header?.classList.remove('top-snowy-header')
}
}
}
@@ -621,6 +648,7 @@
const onLayoutResize = () => {
const clientWidth = document.body.clientWidth
store.setIsMobile(clientWidth < 992)
store.setIsPhone(clientWidth < 768)
}
// 切换应用
const switchModule = (id) => {
@@ -1,8 +1,8 @@
<template>
<a-layout>
<a-layout-sider
v-if="!isMobile"
:collapsed="menuIsCollapse"
v-if="!isPhone"
:collapsed="sideCollapsed"
:trigger="null"
collapsible
:theme="sideTheme"
@@ -13,11 +13,13 @@
<div class="snowy-header-left">
<div class="logo-bar">
<img class="logo" :src="sysBaseConfig.SNOWY_SYS_LOGO" />
<span>{{ sysBaseConfig.SNOWY_SYS_NAME }}</span>
<span :style="{ fontSize: sysNameFontSize }" :title="sysBaseConfig.SNOWY_SYS_NAME">{{
sysBaseConfig.SNOWY_SYS_NAME
}}</span>
</div>
</div>
</header>
<div :class="menuIsCollapse ? 'admin-ui-side isCollapse' : 'admin-ui-side'">
<div :class="sideCollapsed ? 'admin-ui-side isCollapse' : 'admin-ui-side'">
<div class="admin-ui-side-scroll">
<a-menu
:openKeys="openKeys"
@@ -34,13 +36,13 @@
</div>
</a-layout-sider>
<!-- 手机端情况下的左侧菜单 -->
<Side-m v-if="isMobile" v-show="displayLayout" />
<Side-m v-if="isPhone" v-show="displayLayout" />
<!-- 右侧布局 -->
<a-layout>
<div id="snowyHeader" class="snowy-header" v-show="displayLayout">
<div class="snowy-header-left xn-pl0">
<div v-if="!isMobile" class="panel-item hidden-sm-and-down" @click="menuIsCollapseClick">
<MenuUnfoldOutlined v-if="menuIsCollapse" />
<div v-if="!isPhone" class="panel-item hidden-sm-and-down" @click="menuIsCollapseClick">
<MenuUnfoldOutlined v-if="sideCollapsed" />
<MenuFoldOutlined v-else />
</div>
<moduleMenu v-if="moduleMenuShow" @switchModule="switchModule" />
@@ -92,7 +94,9 @@
const props = defineProps({
layout: { type: String }, // 布局信息
isMobile: { type: Boolean }, // 是否移动端
isMobile: { type: Boolean }, // 是否移动端(窄屏,含平板与手机)
isPhone: { type: Boolean }, // 是否手机尺寸(小于 768px)
isTablet: { type: Boolean }, // 是否平板尺寸(768px ~ 991px)
menuIsCollapse: { type: Boolean }, // 菜单是否折叠
sideTheme: { type: String },
sysBaseConfig: { type: Object },
@@ -106,6 +110,43 @@
moduleMenuShow: { type: Boolean }
})
const emit = defineEmits(['onSelect', 'onOpenChange', 'switchModule', 'menuIsCollapseClick', 'displayLayoutChange'])
// 系统名称的视觉宽度权重:中文与全角符号按一个字宽计,英文数字按约六成字宽计
const sysNameWeight = computed(() => {
const sysName = props.sysBaseConfig ? props.sysBaseConfig.SNOWY_SYS_NAME || '' : ''
let weight = 0
for (const word of sysName) {
weight += /[一-龥＀-￯]/.test(word) ? 1 : 0.6
}
return weight
})
// 系统名称字号按长度分档,避免长名称顶到侧边栏右边缘造成左右留白失衡
const sysNameFontSize = computed(() => {
const weight = sysNameWeight.value
if (weight <= 6) {
return '20px'
}
if (weight <= 8) {
return '17px'
}
if (weight <= 10) {
return '14px'
}
return '13px'
})
// 平板尺寸下侧边菜单默认收成图标栏,给内容区让出横向空间
const tabletSideCollapse = ref(true)
const sideCollapsed = computed(() => {
return props.isTablet ? tabletSideCollapse.value : props.menuIsCollapse
})
// 每次进入平板尺寸都重新收起,避免沿用上一次在平板下手动展开的状态
watch(
() => props.isTablet,
(newValue) => {
if (newValue) {
tabletSideCollapse.value = true
}
}
)
const displayLayout = ref(true)
const route = useRoute()
watch(route, () => {
@@ -144,6 +185,11 @@
emit('switchModule', id)
}
const menuIsCollapseClick = () => {
// 平板尺寸下折叠状态仅本地生效,不写入用户的全局菜单折叠配置
if (props.isTablet) {
tabletSideCollapse.value = !tabletSideCollapse.value
return
}
emit('menuIsCollapseClick')
}
</script>
@@ -190,6 +236,34 @@
.main-content-wrapper-max {
padding: 0;
}
/* 系统名称区:logo 与名称整组水平居中,左右留白自动对称 */
.snowy-header-logo {
justify-content: center;
padding: 0 10px;
}
/* 清掉出厂的 20px 左内边距,否则整组会被顶得右偏 */
.snowy-header-logo .snowy-header-left {
padding-left: 0;
min-width: 0;
}
.snowy-header-logo .logo-bar {
min-width: 0;
}
.snowy-header-logo .logo-bar .logo {
flex-shrink: 0;
margin-right: 8px;
}
/* 字号已按字数分档,这里再兜一层省略号,超长时可悬停看全称 */
.snowy-header-logo .logo-bar > span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 侧边栏折叠后名称已隐藏,清掉它预留的右间距,让 logo 正居中 */
.ant-layout-sider-collapsed .snowy-header-logo .logo-bar .logo {
margin-right: 0;
}
.no-radius-menu :deep(.ant-menu-item),
.no-radius-menu :deep(.ant-menu-submenu-title) {
border-radius: 0 !important;
@@ -1,6 +1,14 @@
<template>
<a-layout>
<a-layout-sider v-if="!isMobile" width="80" :theme="sideTheme" :trigger="null" collapsible v-show="displayLayout">
<a-layout-sider
v-if="!isPhone"
:width="firstSideWidth"
:class="{ 'doublerow-first-side-tablet': isTablet }"
:theme="sideTheme"
:trigger="null"
collapsible
v-show="displayLayout"
>
<header id="snowyHeaderLogo" class="snowy-header-logo">
<div class="snowy-header-left">
<div class="logo-bar">
@@ -32,9 +40,13 @@
>
<a v-if="item.meta && item.meta.type === 'link'" :href="item.path" target="_blank" @click.stop="() => {}" />
<template #icon>
<component :is="item.meta.icon" class="xn-pl10" />
<!-- 平板尺寸下只留图标,模块名改由右侧气泡提示承载 -->
<a-tooltip v-if="isTablet" placement="right" :title="item.meta.title">
<component :is="item.meta.icon" />
</a-tooltip>
<component v-else :is="item.meta.icon" class="xn-pl10" />
</template>
<div class="snowy-doublerow-layout-menu-item-fort-div">
<div v-if="!isTablet" class="snowy-doublerow-layout-menu-item-fort-div">
<span class="snowy-doublerow-layout-menu-item-fort-div-span">
{{ item.meta.title }}
</span>
@@ -43,7 +55,7 @@
</a-menu>
</a-layout-sider>
<!-- 手机端情况下的左侧菜单 -->
<Side-m v-if="isMobile" v-show="displayLayout" />
<Side-m v-if="isPhone" v-show="displayLayout" />
<a-layout>
<div id="snowyHeader" class="snowy-header" v-show="displayLayout">
<div class="snowy-header-left xn-pl0">
@@ -56,16 +68,16 @@
<a-layout>
<div v-show="displayLayout"></div>
<a-layout-sider
v-if="!isMobile"
v-if="!isPhone"
v-show="displayLayout && layoutSiderDowbleMenu"
:collapsed="menuIsCollapse"
:collapsed="secondSideCollapsed"
:trigger="null"
width="170"
collapsible
:theme="secondMenuSideTheme"
>
<a-menu
:collapsed="menuIsCollapse"
:collapsed="secondSideCollapsed"
:openKeys="openKeys"
:selectedKeys="selectedKeys"
mode="inline"
@@ -112,7 +124,9 @@
const props = defineProps({
layout: { type: String }, // 布局信息
isMobile: { type: Boolean }, // 是否移动端
isMobile: { type: Boolean }, // 是否移动端(窄屏,含平板与手机)
isPhone: { type: Boolean }, // 是否手机尺寸(小于 768px)
isTablet: { type: Boolean }, // 是否平板尺寸(768px ~ 991px)
sideTheme: { type: String },
menuIsCollapse: {},
sysBaseConfig: { type: Object },
@@ -129,6 +143,14 @@
moduleMenuShow: { type: Boolean },
secondMenuSideTheme: {}
})
// 平板尺寸下一级目录栏收成纯图标栏,桌面保持出厂的 80
const firstSideWidth = computed(() => {
return props.isTablet ? 56 : 80
})
// 平板尺寸下二级菜单收成图标栏,避免两排侧边栏占去内容区过多横向空间
const secondSideCollapsed = computed(() => {
return props.isTablet ? true : props.menuIsCollapse
})
const emit = defineEmits(['onSelect', 'switchModule', 'showMenu', 'displayLayoutChange'])
const displayLayout = ref(true)
const route = useRoute()
@@ -211,4 +233,25 @@
.main-content-wrapper-max {
padding: 0;
}
/* 平板尺寸下第一排收成纯图标栏:logo 居中,菜单项去掉左右内边距让图标居中并放大 */
.doublerow-first-side-tablet {
:deep(.snowy-header-logo) {
justify-content: center;
}
/* 清掉 snowy-header-left 的 20px 左内边距,否则 logo 会被顶得右偏 */
:deep(.snowy-header-logo .snowy-header-left) {
padding-left: 0;
}
:deep(.snowy-header-logo .logo-bar .logo) {
margin-right: 0;
}
:deep(.ant-menu-item .ant-menu-item-icon) {
font-size: 18px;
margin-inline-end: 0 !important;
}
/* 标题已隐藏,清掉图标与标题之间的预留间距,否则图标会左偏 */
:deep(.ant-menu-item .ant-menu-item-icon + span) {
margin-inline-start: 0 !important;
}
}
</style>
+12 -3
View File
@@ -13,7 +13,8 @@
</header>
</div>
<moduleMenu v-if="moduleMenuShow" @switchModule="switchModule" class="xn-pdl25" />
<div class="xn-navmenu-line" id="xn-line-nav">
<!-- 手机尺寸下顶栏放不下横向目录菜单,改由左下角悬浮按钮的抽屉菜单承载 -->
<div class="xn-navmenu-line" id="xn-line-nav" v-if="!isPhone">
<a-menu
class="xn-bb0"
id="topHeaderMenu"
@@ -32,7 +33,7 @@
</div>
</div>
<!-- 手机端情况下的左侧菜单 -->
<Side-m v-if="isMobile" v-show="displayLayout" />
<Side-m v-if="isPhone" v-show="displayLayout" />
<breadcrumb v-if="!isMobile && breadcrumbOpen" v-show="displayLayout" />
<!-- 多标签 -->
<Tags v-if="!isMobile && layoutTagsOpen" v-show="displayLayout" />
@@ -76,7 +77,9 @@
selectedKeys: { type: Array },
openKeys: { type: Array },
sideTheme: { type: String },
isMobile: { type: Boolean }, // 是否移动端
isMobile: { type: Boolean }, // 是否移动端(窄屏,含平板与手机)
isPhone: { type: Boolean }, // 是否手机尺寸(小于 768px)
isTablet: { type: Boolean }, // 是否平板尺寸(768px ~ 991px)
breadcrumbOpen: { type: Boolean }, //面包屑
layoutTagsOpen: { type: Boolean },
layoutSiderDowbleMenu: { type: Boolean },
@@ -157,6 +160,12 @@
.xn-pd050 {
padding: 0 50px;
}
/* 手机尺寸下顶栏两侧留白收窄,避免 logo 与用户栏被挤压 */
@media (max-width: 767px) {
.xn-pd050 {
padding: 0 10px;
}
}
.xn-pl10 {
padding-left: 10px;
}
+8 -1
View File
@@ -28,8 +28,10 @@ const getCacheConfig = (value) => {
// deprecated 请使用 useGlobalStore
export const globalStore = defineStore('global', () => {
// 移动端布局
// 移动端布局(窄屏,小于 992px,含平板与手机)
const isMobile = ref(false)
// 手机布局(小于 768px),平板尺寸为 isMobile 为真但 isPhone 为假
const isPhone = ref(false)
// 布局
const layout = ref(getCacheConfig('SNOWY_LAYOUT'))
// 菜单是否折叠 toggle
@@ -77,6 +79,9 @@ export const globalStore = defineStore('global', () => {
const setIsMobile = (key) => {
isMobile.value = key
}
const setIsPhone = (key) => {
isPhone.value = key
}
const setIsLocked = (key) => {
isLocked.value = key
tool.data.set('SNOWY_IS_LOCKED', key)
@@ -161,6 +166,7 @@ export const globalStore = defineStore('global', () => {
}
return {
isMobile,
isPhone,
layout,
menuIsCollapse,
sideUniqueOpen,
@@ -183,6 +189,7 @@ export const globalStore = defineStore('global', () => {
isLocked,
autoLockTime,
setIsMobile,
setIsPhone,
setIsLocked,
setAutoLockTime,
setLayout,