
文章目录
-
- 第一部分:问题深度剖析与诊断方法
-
- [1.1 Elements Plus 响应式设计失效问题全面分析](#1.1 Elements Plus 响应式设计失效问题全面分析)
- [1.2 诊断与测试方法](#1.2 诊断与测试方法)
- 第二部分:核心解决方案与实现原理
-
- [2.1 方案一:自定义响应式断点系统](#2.1 方案一:自定义响应式断点系统)
- [2.2 方案二:触摸设备专属优化方案](#2.2 方案二:触摸设备专属优化方案)
- 第三部分:组件级自适应优化
-
- [3.1 表格组件响应式优化](#3.1 表格组件响应式优化)
- [3.2 表单组件响应式优化](#3.2 表单组件响应式优化)

第一部分:问题深度剖析与诊断方法

1.1 Elements Plus 响应式设计失效问题全面分析
1.1.1 问题现象具体描述
在实际开发中,Elements Plus 组件库在 PC 端和 iPad 等平板设备上常出现以下自适应失效问题:
- 布局断裂与溢出问题:在 iPad 竖屏模式(768px 宽度)下,多列布局仍保持 PC 端的多列显示,导致内容挤压、重叠或水平滚动条出现
- 字体与间距比例失调:文本字号、行高、组件间距未随屏幕尺寸等比缩放,在 iPad 上显得过大或过小
- 交互元素尺寸不适配:按钮、输入框等交互元素在触摸屏设备上尺寸过小,难以精准点击
- 导航与菜单组件显示异常:侧边栏、导航菜单在有限宽度下未自动折叠或调整布局
- 表格数据展示混乱:数据表格列宽固定,在小屏幕设备上出现内容截断或布局错乱
- 模态框与弹出层定位错误:对话框、提示框等弹出组件未根据视口尺寸重新定位和调整大小
- 图片与媒体内容未自适应:图片保持固定尺寸,未根据容器宽度等比缩放
1.1.2 根本原因分析
-
断点系统不匹配:Elements Plus 默认响应式断点与 iPad 实际屏幕尺寸不匹配
- 默认
sm断点为 640px,而 iPad 竖屏宽度为 768px - iPad Pro(1024px)处于
md和lg断点之间,导致样式应用不准确
- 默认
-
视口与像素密度差异:iPad 等设备具有不同的像素密度(Retina 屏为 2x),CSS 像素与物理像素不对等
-
触摸与鼠标交互差异 :PC 端为指针设备(鼠标),iPad 为触摸设备,
:hover状态在触摸设备上表现异常 -
REM 基准值计算差异:根元素字体大小计算方式在不同浏览器和设备上不一致
-
CSS 媒体查询覆盖不全:部分组件的响应式样式可能被全局样式或自定义样式覆盖
1.2 诊断与测试方法
1.2.1 响应式断点检测工具
javascript
// 响应式断点检测组件
import { ref, onMounted, onUnmounted } from 'vue'
export function useBreakpointDetection() {
const breakpoint = ref('')
const screenWidth = ref(0)
const deviceType = ref('')
const pixelRatio = ref(1)
// Elements Plus 默认断点
const BREAKPOINTS = {
'xs': 0,
'sm': 640,
'md': 768,
'lg': 1024,
'xl': 1280,
'2xl': 1536
}
// 设备类型检测
const detectDeviceType = (width) => {
if (width < 640) return 'mobile'
if (width >= 640 && width < 768) return 'small-tablet'
if (width >= 768 && width < 1024) return 'tablet' // iPad 竖屏
if (width >= 1024 && width < 1280) return 'tablet-landscape' // iPad 横屏
if (width >= 1280 && width < 1536) return 'desktop'
return 'large-desktop'
}
// 检测当前断点
const detectBreakpoint = (width) => {
if (width < BREAKPOINTS.sm) return 'xs'
if (width >= BREAKPOINTS.sm && width < BREAKPOINTS.md) return 'sm'
if (width >= BREAKPOINTS.md && width < BREAKPOINTS.lg) return 'md'
if (width >= BREAKPOINTS.lg && width < BREAKPOINTS.xl) return 'lg'
if (width >= BREAKPOINTS.xl && width < BREAKPOINTS['2xl']) return 'xl'
return '2xl'
}
const updateBreakpoint = () => {
const width = window.innerWidth
screenWidth.value = width
breakpoint.value = detectBreakpoint(width)
deviceType.value = detectDeviceType(width)
pixelRatio.value = window.devicePixelRatio || 1
// 调试信息输出
console.log(`当前断点: ${breakpoint.value}`)
console.log(`屏幕宽度: ${width}px`)
console.log(`设备类型: ${deviceType.value}`)
console.log(`像素密度: ${pixelRatio.value}`)
console.log(`当前视口: ${document.documentElement.clientWidth}px`)
}
onMounted(() => {
updateBreakpoint()
window.addEventListener('resize', updateBreakpoint)
// 检测触摸设备
const isTouchDevice = 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
console.log(`触摸设备: ${isTouchDevice}`)
// 检测 Safari on iPad
const isSafariOniPad = /iPad|iPhone|iPod/.test(navigator.userAgent) &&
!window.MSStream &&
'ontouchend' in document
console.log(`iPad Safari: ${isSafariOniPad}`)
})
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoint)
})
return {
breakpoint,
screenWidth,
deviceType,
pixelRatio
}
}
// 在 Vue 组件中使用
export default {
setup() {
const { breakpoint, screenWidth, deviceType } = useBreakpointDetection()
return {
breakpoint,
screenWidth,
deviceType
}
}
}
1.2.2 CSS 覆盖检测脚本
javascript
// CSS 样式检测工具
export function analyzeElementStyles(elementSelector) {
const element = document.querySelector(elementSelector)
if (!element) return null
const computedStyles = window.getComputedStyle(element)
const elementStyles = {}
// 获取关键样式属性
const importantProperties = [
'width', 'height', 'max-width', 'min-width',
'padding', 'margin', 'display', 'flex-direction',
'grid-template-columns', 'font-size', 'line-height'
]
importantProperties.forEach(prop => {
elementStyles[prop] = computedStyles.getPropertyValue(prop)
})
// 检测媒体查询覆盖
const mediaQueryOverrides = detectMediaQueryOverrides(elementSelector)
// 检测内联样式
const inlineStyles = element.getAttribute('style') || ''
// 检测 !important 规则
const importantRules = detectImportantRules(elementSelector)
return {
element: elementSelector,
computedStyles: elementStyles,
mediaQueryOverrides,
inlineStyles,
importantRules,
classList: Array.from(element.classList)
}
}
function detectMediaQueryOverrides(elementSelector) {
const overrides = []
const sheets = document.styleSheets
for (let i = 0; i < sheets.length; i++) {
try {
const rules = sheets[i].cssRules || sheets[i].rules
for (let j = 0; j < rules.length; j++) {
const rule = rules[j]
if (rule.media && rule.cssRules) {
for (let k = 0; k < rule.cssRules.length; k++) {
const mediaRule = rule.cssRules[k]
if (mediaRule.selectorText &&
mediaRule.selectorText.includes(elementSelector)) {
overrides.push({
mediaQuery: rule.media.mediaText,
cssText: mediaRule.cssText
})
}
}
}
}
} catch (e) {
// 跨域样式表可能无法访问
console.warn('无法访问样式表:', e)
}
}
return overrides
}
1.2.3 响应式测试工具集
html
<!-- 响应式测试工具组件 -->
<template>
<div class="responsive-debug-tool">
<div class="debug-info" :style="infoStyle">
<div>宽度: {{ screenWidth }}px</div>
<div>断点: {{ breakpoint }}</div>
<div>设备: {{ deviceType }}</div>
<div>像素比: {{ pixelRatio }}</div>
<div>视口: {{ viewportWidth }}px</div>
</div>
<div class="breakpoint-indicators">
<div
v-for="bp in breakpoints"
:key="bp.name"
:class="['indicator', { active: screenWidth >= bp.min }]"
:style="{ left: `${(bp.min / 1920) * 100}%` }"
>
<span class="label">{{ bp.name }} ({{ bp.min }}px)</span>
</div>
</div>
<button @click="toggleGrid" class="grid-toggle">
{{ showGrid ? '隐藏' : '显示' }}栅格
</button>
<div v-if="showGrid" class="responsive-grid">
<div class="grid-column" v-for="n in 12" :key="n"></div>
</div>
</div>
</template>
<script>
import { ref, onMounted, onUnmounted } from 'vue'
export default {
setup() {
const screenWidth = ref(window.innerWidth)
const breakpoint = ref('')
const deviceType = ref('')
const pixelRatio = ref(window.devicePixelRatio || 1)
const viewportWidth = ref(document.documentElement.clientWidth)
const showGrid = ref(false)
const breakpoints = [
{ name: 'xs', min: 0 },
{ name: 'sm', min: 640 },
{ name: 'md', min: 768 },
{ name: 'lg', min: 1024 },
{ name: 'xl', min: 1280 },
{ name: '2xl', min: 1536 }
]
const updateViewportInfo = () => {
screenWidth.value = window.innerWidth
viewportWidth.value = document.documentElement.clientWidth
pixelRatio.value = window.devicePixelRatio || 1
// 检测断点
for (let i = breakpoints.length - 1; i >= 0; i--) {
if (screenWidth.value >= breakpoints[i].min) {
breakpoint.value = breakpoints[i].name
break
}
}
// 检测设备类型
if (screenWidth.value < 768) {
deviceType.value = '移动设备'
} else if (screenWidth.value < 1024) {
deviceType.value = '平板(竖屏)'
} else if (screenWidth.value < 1280) {
deviceType.value = '平板(横屏)'
} else {
deviceType.value = '桌面设备'
}
}
const toggleGrid = () => {
showGrid.value = !showGrid.value
}
onMounted(() => {
updateViewportInfo()
window.addEventListener('resize', updateViewportInfo)
window.addEventListener('orientationchange', updateViewportInfo)
})
onUnmounted(() => {
window.removeEventListener('resize', updateViewportInfo)
window.removeEventListener('orientationchange', updateViewportInfo)
})
const infoStyle = {
position: 'fixed',
top: '10px',
right: '10px',
background: 'rgba(0,0,0,0.8)',
color: '#fff',
padding: '10px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 9999
}
return {
screenWidth,
breakpoint,
deviceType,
pixelRatio,
viewportWidth,
showGrid,
breakpoints,
toggleGrid,
infoStyle
}
}
}
</script>
<style scoped>
.responsive-debug-tool {
font-family: monospace;
}
.breakpoint-indicators {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, #ff6b6b, #ffd93d, #6bcf7f, #4d96ff);
z-index: 9998;
}
.indicator {
position: absolute;
top: -10px;
transform: translateX(-50%);
}
.indicator .label {
position: absolute;
top: 10px;
white-space: nowrap;
font-size: 10px;
color: #666;
display: none;
}
.indicator.active .label {
display: block;
}
.indicator::after {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 2px;
height: 10px;
background: #333;
}
.grid-toggle {
position: fixed;
bottom: 20px;
right: 20px;
padding: 8px 16px;
background: #4d96ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
z-index: 9999;
}
.responsive-grid {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9997;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
padding: 0 16px;
box-sizing: border-box;
}
.grid-column {
background: rgba(77, 150, 255, 0.1);
border: 1px dashed rgba(77, 150, 255, 0.3);
}
</style>
第二部分:核心解决方案与实现原理
2.1 方案一:自定义响应式断点系统
2.1.1 原理深度解析
Elements Plus 使用 Tailwind CSS 的断点系统作为响应式设计基础,默认断点配置为:
sm: 640pxmd: 768pxlg: 1024pxxl: 1280px2xl: 1536px
问题所在 :iPad 竖屏宽度为 768px,正好落在 md 断点的起始位置。而许多组件在 md 断点的样式并未针对平板设备进行优化,导致显示异常。
解决方案原理:通过扩展和调整断点系统,为 iPad 等设备创建更精细的响应式控制:
- 在 768px-1024px 之间添加平板专用断点
- 优化现有断点的样式覆盖
- 确保断点过渡平滑自然
2.1.2 实现步骤详解
步骤一:创建自定义断点配置文件
javascript
// src/styles/breakpoints.js
export const customBreakpoints = {
// 移动设备断点
'xs': { min: '0px', max: '479px' },
'sm': { min: '480px', max: '639px' },
// 小屏平板断点(7-8寸平板)
'md': { min: '640px', max: '767px' },
// 标准平板断点(iPad 竖屏)
'lg': { min: '768px', max: '1023px' },
// 大屏平板/小屏桌面(iPad 横屏)
'xl': { min: '1024px', max: '1279px' },
// 桌面设备
'2xl': { min: '1280px', max: '1535px' },
// 大屏桌面
'3xl': { min: '1536px', max: null }
}
// 生成媒体查询工具函数
export const createMediaQuery = (breakpoint, direction = 'up') => {
const bp = customBreakpoints[breakpoint]
if (!bp) return ''
if (direction === 'up') {
return `@media (min-width: ${bp.min})`
} else if (direction === 'down') {
if (bp.max) {
return `@media (max-width: ${bp.max})`
}
return ''
} else if (direction === 'only') {
if (bp.max) {
return `@media (min-width: ${bp.min}) and (max-width: ${bp.max})`
}
return `@media (min-width: ${bp.min})`
}
}
// 设备类型检测
export const deviceType = () => {
const width = window.innerWidth
if (width <= 479) return 'mobile'
if (width <= 767) return 'small-tablet'
if (width <= 1023) return 'tablet' // iPad 竖屏
if (width <= 1279) return 'tablet-landscape' // iPad 横屏
if (width <= 1535) return 'desktop'
return 'large-desktop'
}
步骤二:覆盖 Elements Plus 断点配置
javascript
// vue.config.js 或 vite.config.js
module.exports = {
css: {
loaderOptions: {
sass: {
// 注入自定义断点变量
additionalData: `
$--sm: 480px;
$--md: 640px;
$--lg: 768px;
$--xl: 1024px;
$--2xl: 1280px;
// 响应式工具类
@mixin respond-to($breakpoint) {
@if $breakpoint == 'mobile' {
@media (max-width: 479px) { @content; }
}
@else if $breakpoint == 'small-tablet' {
@media (min-width: 480px) and (max-width: 767px) { @content; }
}
@else if $breakpoint == 'tablet' {
@media (min-width: 768px) and (max-width: 1023px) { @content; }
}
@else if $breakpoint == 'tablet-landscape' {
@media (min-width: 1024px) and (max-width: 1279px) { @content; }
}
@else if $breakpoint == 'desktop' {
@media (min-width: 1280px) and (max-width: 1535px) { @content; }
}
@else if $breakpoint == 'large-desktop' {
@media (min-width: 1536px) { @content; }
}
}
`
}
}
}
}
步骤三:创建响应式混合指令
javascript
// src/directives/responsive.js
import { createApp } from 'vue'
const responsiveDirective = {
mounted(el, binding) {
const { value } = binding
const classPrefix = value?.prefix || 'responsive'
const breakpoints = value?.breakpoints || ['sm', 'md', 'lg', 'xl']
// 创建响应式类监听器
const updateResponsiveClasses = () => {
const width = window.innerWidth
// 移除旧的响应式类
breakpoints.forEach(bp => {
el.classList.remove(`${classPrefix}-${bp}`)
el.classList.remove(`${classPrefix}-${bp}-up`)
el.classList.remove(`${classPrefix}-${bp}-down`)
})
// 添加新的响应式类
const breakpointMap = {
'sm': 480,
'md': 640,
'lg': 768,
'xl': 1024,
'2xl': 1280
}
// 检测当前适用的断点
for (const [bp, minWidth] of Object.entries(breakpointMap).reverse()) {
if (width >= minWidth) {
el.classList.add(`${classPrefix}-${bp}`)
el.classList.add(`${classPrefix}-${bp}-up`)
break
}
}
// 添加向下兼容的类
for (const [bp, minWidth] of Object.entries(breakpointMap)) {
if (width < minWidth) {
el.classList.add(`${classPrefix}-${bp}-down`)
break
}
}
}
// 初始更新
updateResponsiveClasses()
// 添加 resize 监听
const handleResize = () => {
requestAnimationFrame(updateResponsiveClasses)
}
window.addEventListener('resize', handleResize)
// 存储清理函数
el._responsiveCleanup = () => {
window.removeEventListener('resize', handleResize)
}
},
unmounted(el) {
if (el._responsiveCleanup) {
el._responsiveCleanup()
}
}
}
// 注册指令
export default {
install(app) {
app.directive('responsive', responsiveDirective)
}
}
// 在 main.js 中使用
import responsiveDirective from './directives/responsive'
createApp(App)
.use(responsiveDirective)
.mount('#app')
步骤四:创建响应式工具类
scss
// src/styles/responsive-utilities.scss
// 响应式显示/隐藏工具类
.responsive-hidden {
&-xs { @media (max-width: 479px) { display: none !important; } }
&-sm { @media (min-width: 480px) and (max-width: 639px) { display: none !important; } }
&-md { @media (min-width: 640px) and (max-width: 767px) { display: none !important; } }
&-lg { @media (min-width: 768px) and (max-width: 1023px) { display: none !important; } }
&-xl { @media (min-width: 1024px) and (max-width: 1279px) { display: none !important; } }
&-2xl { @media (min-width: 1280px) { display: none !important; } }
// 向上隐藏
&-sm-up { @media (min-width: 480px) { display: none !important; } }
&-md-up { @media (min-width: 640px) { display: none !important; } }
&-lg-up { @media (min-width: 768px) { display: none !important; } }
// 向下隐藏
&-md-down { @media (max-width: 767px) { display: none !important; } }
&-lg-down { @media (max-width: 1023px) { display: none !important; } }
&-xl-down { @media (max-width: 1279px) { display: none !important; } }
}
// 响应式间距工具
$spacing-scale: 0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10;
@each $size in $spacing-scale {
$index: index($spacing-scale, $size) - 1;
// 移动端优先的间距
.p-#{$index} { padding: #{$size}rem; }
.m-#{$index} { margin: #{$size}rem; }
// 响应式间距覆盖
@media (min-width: 768px) {
.lg\:p-#{$index} { padding: #{$size * 1.2}rem !important; }
.lg\:m-#{$index} { margin: #{$size * 1.2}rem !important; }
}
@media (min-width: 1024px) {
.xl\:p-#{$index} { padding: #{$size * 1.5}rem !important; }
.xl\:m-#{$index} { margin: #{$size * 1.5}rem !important; }
}
}
// 响应式字体大小
$font-scale: 0.75, 0.875, 1, 1.125, 1.25, 1.5, 1.875, 2.25, 3, 3.75, 4.5;
@for $i from 1 through length($font-scale) {
$size: nth($font-scale, $i);
.text-#{$i} { font-size: #{$size}rem; }
// 平板设备字体调整
@media (min-width: 768px) and (max-width: 1023px) {
.lg\:text-#{$i} { font-size: #{$size * 0.95}rem !important; }
}
// 桌面设备字体调整
@media (min-width: 1024px) {
.xl\:text-#{$i} { font-size: #{$size * 1.1}rem !important; }
}
}
// 响应式 Flex/Grid 布局
.responsive-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
}
@media (min-width: 1024px) {
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
}
@media (min-width: 1280px) {
grid-template-columns: repeat(6, 1fr);
gap: 2rem;
}
}
// 触摸设备优化
.touch-device {
@media (hover: none) and (pointer: coarse) {
// 增大触摸目标
button,
.el-button,
[role="button"] {
min-height: 44px !important;
min-width: 44px !important;
padding: 12px 20px !important;
}
// 输入框优化
input,
.el-input__inner,
textarea {
font-size: 16px !important; // 防止 iOS 缩放
min-height: 44px !important;
}
// 移除 hover 效果
*:hover {
background-color: inherit !important;
color: inherit !important;
}
}
}
步骤五:创建响应式布局组件
vue
<!-- src/components/layout/ResponsiveContainer.vue -->
<template>
<div
:class="[
'responsive-container',
{
'responsive-container--fluid': fluid,
'responsive-container--full-height': fullHeight,
[`responsive-container--${breakpoint}`]: true
}
]"
:style="containerStyle"
>
<slot />
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted } from 'vue'
export default {
name: 'ResponsiveContainer',
props: {
fluid: {
type: Boolean,
default: false
},
fullHeight: {
type: Boolean,
default: false
},
maxWidth: {
type: String,
default: null
},
gutter: {
type: [String, Number],
default: '16px'
},
adaptivePadding: {
type: Boolean,
default: true
}
},
setup(props) {
const breakpoint = ref('')
const screenWidth = ref(0)
// 响应式断点检测
const detectBreakpoint = (width) => {
if (width < 480) return 'xs'
if (width < 640) return 'sm'
if (width < 768) return 'md'
if (width < 1024) return 'lg'
if (width < 1280) return 'xl'
return '2xl'
}
const updateBreakpoint = () => {
screenWidth.value = window.innerWidth
breakpoint.value = detectBreakpoint(screenWidth.value)
}
onMounted(() => {
updateBreakpoint()
window.addEventListener('resize', updateBreakpoint)
})
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoint)
})
// 计算容器样式
const containerStyle = computed(() => {
const styles = {}
// 设置最大宽度
if (!props.fluid) {
const maxWidths = {
'xs': '100%',
'sm': '540px',
'md': '720px',
'lg': '960px',
'xl': '1140px',
'2xl': '1320px'
}
styles.maxWidth = props.maxWidth || maxWidths[breakpoint.value] || '1320px'
styles.marginLeft = 'auto'
styles.marginRight = 'auto'
}
// 设置内边距
if (props.adaptivePadding) {
const paddings = {
'xs': '12px',
'sm': '16px',
'md': '20px',
'lg': '24px',
'xl': '32px',
'2xl': '40px'
}
const padding = paddings[breakpoint.value] || '24px'
styles.paddingLeft = padding
styles.paddingRight = padding
} else if (props.gutter) {
const gutter = typeof props.gutter === 'number' ? `${props.gutter}px` : props.gutter
styles.paddingLeft = gutter
styles.paddingRight = gutter
}
// 设置高度
if (props.fullHeight) {
styles.minHeight = '100vh'
}
return styles
})
// 提供响应式上下文
provide('responsiveContext', {
breakpoint,
screenWidth,
isMobile: computed(() => ['xs', 'sm'].includes(breakpoint.value)),
isTablet: computed(() => ['md', 'lg'].includes(breakpoint.value)),
isDesktop: computed(() => ['xl', '2xl'].includes(breakpoint.value))
})
return {
breakpoint,
containerStyle
}
}
}
</script>
<style scoped>
.responsive-container {
width: 100%;
box-sizing: border-box;
transition: all 0.3s ease;
}
.responsive-container--fluid {
max-width: 100% !important;
}
.responsive-container--full-height {
display: flex;
flex-direction: column;
}
/* 平板设备优化 */
@media (min-width: 768px) and (max-width: 1023px) {
.responsive-container--lg {
--container-padding: 24px;
--grid-gap: 20px;
}
}
/* 平板横屏优化 */
@media (min-width: 1024px) and (max-width: 1279px) {
.responsive-container--xl {
--container-padding: 32px;
--grid-gap: 24px;
}
}
</style>
2.2 方案二:触摸设备专属优化方案
2.2.1 触摸设备特性分析
- 交互差异:触摸设备无 hover 状态,需要提供视觉反馈
- 精度差异:手指触摸精度低于鼠标指针,需要更大的点击目标
- 手势支持:支持滑动、捏合等手势操作
- 虚拟键盘:输入时虚拟键盘弹出,影响布局
2.2.2 触摸优化实现方案
vue
<!-- src/components/touch/TouchOptimizer.vue -->
<template>
<div :class="touchClasses">
<slot />
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue'
export default {
name: 'TouchOptimizer',
props: {
enabled: {
type: Boolean,
default: true
},
minTouchSize: {
type: Number,
default: 44
},
optimizeInputs: {
type: Boolean,
default: true
},
disableHover: {
type: Boolean,
default: true
}
},
setup(props) {
const isTouchDevice = ref(false)
const isIOS = ref(false)
const isIPad = ref(false)
// 检测设备类型
const detectDevice = () => {
// 检测触摸设备
isTouchDevice.value = 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
// 检测 iOS 设备
const ua = navigator.userAgent
isIOS.value = /iPad|iPhone|iPod/.test(ua) && !window.MSStream
// 检测 iPad
isIPad.value = /iPad/.test(ua) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
console.log('设备检测:', {
isTouchDevice: isTouchDevice.value,
isIOS: isIOS.value,
isIPad: isIPad.value,
userAgent: ua
})
}
// 优化触摸交互
const optimizeTouchInteraction = () => {
if (!props.enabled || !isTouchDevice.value) return
// 动态添加触摸优化样式
const styleId = 'touch-optimization-styles'
if (document.getElementById(styleId)) return
const style = document.createElement('style')
style.id = styleId
const minSize = props.minTouchSize
style.textContent = `
/* 触摸设备优化样式 */
@media (hover: none) and (pointer: coarse) {
/* 增大点击目标 */
button,
.el-button,
[role="button"],
.clickable {
min-height: ${minSize}px !important;
min-width: ${minSize}px !important;
padding: 12px 20px !important;
}
/* 优化输入框 */
input,
.el-input__inner,
textarea,
select {
font-size: 16px !important;
min-height: ${minSize}px !important;
padding: 12px !important;
}
/* 优化表单元素 */
.el-radio,
.el-checkbox {
min-height: ${minSize}px !important;
}
.el-radio__inner,
.el-checkbox__inner {
transform: scale(1.2);
transform-origin: left center;
}
/* 优化表格交互 */
.el-table__row {
min-height: ${minSize}px !important;
}
/* 禁用 hover 效果 */
${props.disableHover ? `
*:hover {
background-color: inherit !important;
color: inherit !important;
transform: none !important;
}
/* 用 active 状态替代 hover */
button:active,
.el-button:active,
[role="button"]:active {
opacity: 0.8;
transform: scale(0.98);
}
` : ''}
/* iOS 特定优化 */
${isIOS.value ? `
/* 防止 iOS 缩放 */
input[type="text"],
input[type="search"],
input[type="tel"],
input[type="url"],
input[type="email"] {
font-size: 16px !important;
}
/* 优化 Safari 渲染 */
* {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
}
/* 平滑滚动 */
.scrollable {
-webkit-overflow-scrolling: touch;
}
` : ''}
/* iPad 特定优化 */
${isIPad.value ? `
/* 优化 iPad 上的鼠标指针 */
@media (pointer: fine) and (hover: hover) {
/* iPad 连接鼠标时 */
.el-button:hover {
background-color: var(--el-button-hover-bg-color) !important;
}
}
/* 优化 iPad 分屏模式 */
@media (max-width: 1024px) {
.responsive-container {
padding-left: env(safe-area-inset-left) !important;
padding-right: env(safe-area-inset-right) !important;
}
}
` : ''}
}
/* 触摸反馈动画 */
.touch-feedback {
position: relative;
overflow: hidden;
}
.touch-feedback::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
pointer-events: none;
}
.touch-feedback:active::after {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
transition: 0s;
}
`
document.head.appendChild(style)
}
// 优化虚拟键盘处理
const optimizeVirtualKeyboard = () => {
if (!props.optimizeInputs) return
// 监听输入框聚焦,滚动到可视区域
const handleFocus = (event) => {
if (!isTouchDevice.value) return
const target = event.target
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
setTimeout(() => {
target.scrollIntoView({
behavior: 'smooth',
block: 'center'
})
}, 300)
}
}
// 监听视口变化(虚拟键盘弹出)
const handleResize = () => {
if (!isTouchDevice.value) return
const viewportHeight = window.innerHeight
const documentHeight = document.documentElement.clientHeight
// 检测虚拟键盘是否弹出
if (viewportHeight < documentHeight * 0.7) {
document.body.classList.add('virtual-keyboard-open')
} else {
document.body.classList.remove('virtual-keyboard-open')
}
}
document.addEventListener('focusin', handleFocus)
window.addEventListener('resize', handleResize)
return () => {
document.removeEventListener('focusin', handleFocus)
window.removeEventListener('resize', handleResize)
}
}
// 添加手势支持
const addGestureSupport = () => {
if (!isTouchDevice.value) return
// 滑动手势支持
const touchStart = { x: 0, y: 0 }
const handleTouchStart = (event) => {
const touch = event.touches[0]
touchStart.x = touch.clientX
touchStart.y = touch.clientY
}
const handleTouchEnd = (event) => {
const touch = event.changedTouches[0]
const deltaX = touch.clientX - touchStart.x
const deltaY = touch.clientY - touchStart.y
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
// 检测滑动手势
if (distance > 50) {
const eventDetail = {
direction: Math.abs(deltaX) > Math.abs(deltaY)
? (deltaX > 0 ? 'right' : 'left')
: (deltaY > 0 ? 'down' : 'up'),
distance: distance,
startX: touchStart.x,
startY: touchStart.y,
endX: touch.clientX,
endY: touch.clientY
}
// 触发自定义手势事件
const gestureEvent = new CustomEvent('touch-gesture', {
detail: eventDetail,
bubbles: true
})
event.target.dispatchEvent(gestureEvent)
}
}
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
return () => {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
}
}
onMounted(() => {
detectDevice()
optimizeTouchInteraction()
const cleanupKeyboard = optimizeVirtualKeyboard()
const cleanupGesture = addGestureSupport()
// 清理函数
return () => {
cleanupKeyboard?.()
cleanupGesture?.()
}
})
const touchClasses = computed(() => ({
'touch-optimized': props.enabled && isTouchDevice.value,
'ios-device': isIOS.value,
'ipad-device': isIPad.value,
'touch-feedback': props.enabled
}))
return {
isTouchDevice,
isIOS,
isIPad,
touchClasses
}
}
}
</script>
<style scoped>
.touch-optimized {
--touch-target-size: 44px;
--touch-padding: 12px;
}
/* 虚拟键盘打开时的优化 */
.virtual-keyboard-open .fixed-bottom {
bottom: var(--keyboard-height, 300px);
transition: bottom 0.3s ease;
}
/* 防止内容被键盘遮挡 */
@media (max-height: 500px) {
.virtual-keyboard-open .scrollable-content {
max-height: calc(100vh - var(--keyboard-height, 300px));
overflow-y: auto;
}
}
</style>
2.2.3 触摸友好的 Elements Plus 组件包装器
vue
<!-- src/components/touch/TouchButton.vue -->
<template>
<el-button
ref="buttonRef"
:class="[
'touch-button',
{
'touch-button--large': large,
'touch-button--block': block,
'touch-button--loading': loading,
'touch-button--disabled': disabled || loading
}
]"
:size="computedSize"
:type="type"
:disabled="disabled || loading"
:loading="loading"
@click="handleClick"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
>
<slot />
<span v-if="showTouchHint && isTouchDevice" class="touch-button__hint">
<i class="el-icon-touch" />
</span>
</el-button>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { ElButton } from 'element-plus'
export default {
name: 'TouchButton',
components: {
ElButton
},
props: {
size: {
type: String,
default: 'default',
validator: (value) => ['mini', 'small', 'default', 'large'].includes(value)
},
type: {
type: String,
default: 'default',
validator: (value) =>
['default', 'primary', 'success', 'warning', 'danger', 'info', 'text'].includes(value)
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
large: {
type: Boolean,
default: false
},
block: {
type: Boolean,
default: false
},
showTouchHint: {
type: Boolean,
default: false
},
rippleEffect: {
type: Boolean,
default: true
}
},
setup(props, { emit }) {
const buttonRef = ref(null)
const isTouchDevice = ref(false)
const isPressed = ref(false)
// 检测触摸设备
onMounted(() => {
isTouchDevice.value = 'ontouchstart' in window ||
navigator.maxTouchPoints > 0
// 添加触摸优化样式
if (isTouchDevice.value) {
const style = document.createElement('style')
style.textContent = `
.touch-button {
position: relative;
overflow: hidden;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.touch-button--large {
min-height: 48px !important;
min-width: 48px !important;
padding: 14px 24px !important;
font-size: 16px !important;
}
.touch-button--block {
width: 100% !important;
display: block !important;
}
.touch-button__ripple {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
transform: scale(0);
animation: ripple 0.6s linear;
pointer-events: none;
}
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}
.touch-button__hint {
margin-left: 8px;
opacity: 0.6;
font-size: 0.9em;
}
.touch-button:active {
transform: scale(0.98);
transition: transform 0.1s;
}
.touch-button--loading {
opacity: 0.7;
cursor: wait !important;
}
.touch-button--disabled {
opacity: 0.5;
cursor: not-allowed !important;
}
`
document.head.appendChild(style)
}
})
// 计算按钮尺寸
const computedSize = computed(() => {
if (isTouchDevice.value && props.large) {
return 'large'
}
return props.size
})
// 涟漪效果
const createRipple = (event) => {
if (!props.rippleEffect || !buttonRef.value) return
const button = buttonRef.value.$el
const circle = document.createElement('span')
const diameter = Math.max(button.clientWidth, button.clientHeight)
const radius = diameter / 2
circle.style.width = circle.style.height = `${diameter}px`
circle.style.left = `${event.clientX - button.getBoundingClientRect().left - radius}px`
circle.style.top = `${event.clientY - button.getBoundingClientRect().top - radius}px`
circle.classList.add('touch-button__ripple')
const ripple = button.getElementsByClassName('touch-button__ripple')[0]
if (ripple) {
ripple.remove()
}
button.appendChild(circle)
}
// 事件处理
const handleClick = (event) => {
if (!isTouchDevice.value) {
createRipple(event)
}
emit('click', event)
}
const handleTouchStart = (event) => {
if (!isTouchDevice.value) return
isPressed.value = true
createRipple(event.touches[0])
// 添加按下状态类
if (buttonRef.value?.$el) {
buttonRef.value.$el.classList.add('touch-button--pressed')
}
}
const handleTouchEnd = () => {
if (!isTouchDevice.value) return
isPressed.value = false
// 移除按下状态类
if (buttonRef.value?.$el) {
buttonRef.value.$el.classList.remove('touch-button--pressed')
}
}
return {
buttonRef,
isTouchDevice,
isPressed,
computedSize,
handleClick,
handleTouchStart,
handleTouchEnd
}
}
}
</script>
第三部分:组件级自适应优化
3.1 表格组件响应式优化
3.1.1 问题分析
Elements Plus 表格在 iPad 等中等屏幕设备上常出现以下问题:
- 列过多导致水平滚动
- 列宽固定,内容被截断
- 操作按钮过小,难以点击
- 缺少移动端友好视图
3.1.2 响应式表格组件实现
vue
<!-- src/components/table/ResponsiveTable.vue -->
<template>
<div
:class="[
'responsive-table-container',
{
'responsive-table-container--striped': striped,
'responsive-table-container--bordered': bordered,
'responsive-table-container--hover': hover,
'responsive-table-container--small': size === 'small',
'responsive-table-container--large': size === 'large',
'responsive-table-container--card': cardView,
[`responsive-table-container--${breakpoint}`]: true
}
]"
:style="containerStyle"
>
<!-- 工具栏 -->
<div v-if="showToolbar" class="responsive-table__toolbar">
<div class="responsive-table__toolbar-left">
<slot name="toolbar-left" />
<!-- 视图切换 -->
<div v-if="allowViewToggle" class="view-toggle">
<el-button-group>
<el-button
:type="viewMode === 'table' ? 'primary' : 'default'"
size="small"
@click="viewMode = 'table'"
:title="isMobile ? '列表视图' : '表格视图'"
>
<i class="el-icon-s-grid" />
<span v-if="!isMobile">表格</span>
</el-button>
<el-button
:type="viewMode === 'card' ? 'primary' : 'default'"
size="small"
@click="viewMode = 'card'"
:title="isMobile ? '卡片视图' : '卡片视图'"
>
<i class="el-icon-s-data" />
<span v-if="!isMobile">卡片</span>
</el-button>
</el-button-group>
</div>
</div>
<div class="responsive-table__toolbar-right">
<!-- 列显示控制 -->
<el-dropdown
v-if="showColumnControl"
trigger="click"
:disabled="viewMode === 'card'"
>
<el-button size="small">
<i class="el-icon-setting" />
<span v-if="!isMobile">列设置</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="column in columns"
:key="column.prop"
:disabled="column.fixed"
>
<el-checkbox
v-model="column.visible"
@change="handleColumnVisibilityChange(column)"
>
{{ column.label }}
</el-checkbox>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<slot name="toolbar-right" />
</div>
</div>
<!-- 表格视图 -->
<div
v-if="viewMode === 'table'"
:class="[
'responsive-table-wrapper',
{ 'responsive-table-wrapper--scrollable': scrollable }
]"
:style="wrapperStyle"
>
<el-table
ref="tableRef"
:data="data"
:height="height"
:max-height="maxHeight"
:stripe="striped"
:border="bordered"
:size="computedSize"
:row-class-name="rowClassName"
:cell-class-name="cellClassName"
:header-cell-class-name="headerCellClassName"
:show-header="showHeader"
:highlight-current-row="highlightCurrentRow"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
>
<!-- 选择列 -->
<el-table-column
v-if="selectable"
type="selection"
:width="isMobile ? 40 : 55"
:fixed="isMobile ? 'left' : false"
align="center"
/>
<!-- 序号列 -->
<el-table-column
v-if="showIndex"
type="index"
:label="indexLabel"
:width="isMobile ? 50 : 60"
:fixed="isMobile ? 'left' : false"
align="center"
/>
<!-- 动态列 -->
<template v-for="column in visibleColumns" :key="column.prop">
<el-table-column
:prop="column.prop"
:label="column.label"
:width="getColumnWidth(column)"
:min-width="getColumnMinWidth(column)"
:fixed="getColumnFixed(column)"
:align="column.align || 'left'"
:sortable="column.sortable"
:formatter="column.formatter"
:show-overflow-tooltip="column.showTooltip !== false"
>
<!-- 自定义列内容 -->
<template v-if="column.slot" #default="scope">
<slot :name="column.slot" :row="scope.row" />
</template>
</el-table-column>
</template>
<!-- 操作列 -->
<el-table-column
v-if="showActions"
:label="actionLabel"
:width="getActionsWidth"
:fixed="isMobile ? 'right' : false"
align="center"
>
<template #default="scope">
<div class="responsive-table__actions">
<slot name="actions" :row="scope.row" />
<!-- 默认操作按钮 -->
<template v-if="defaultActions">
<el-button
v-if="defaultActions.view"
size="small"
type="text"
@click.stop="handleView(scope.row)"
:title="defaultActions.view.title || '查看'"
>
<i class="el-icon-view" />
<span v-if="!isMobile">{{ defaultActions.view.text || '查看' }}</span>
</el-button>
<el-button
v-if="defaultActions.edit"
size="small"
type="text"
@click.stop="handleEdit(scope.row)"
:title="defaultActions.edit.title || '编辑'"
>
<i class="el-icon-edit" />
<span v-if="!isMobile">{{ defaultActions.edit.text || '编辑' }}</span>
</el-button>
<el-button
v-if="defaultActions.delete"
size="small"
type="text"
@click.stop="handleDelete(scope.row)"
:title="defaultActions.delete.title || '删除'"
>
<i class="el-icon-delete" />
<span v-if="!isMobile">{{ defaultActions.delete.text || '删除' }}</span>
</el-button>
</template>
</div>
</template>
</el-table-column>
<!-- 空状态 -->
<template #empty>
<div class="responsive-table__empty">
<slot name="empty">
<i class="el-icon-document" />
<p>{{ emptyText || '暂无数据' }}</p>
</slot>
</div>
</template>
</el-table>
<!-- 移动端浮动操作按钮 -->
<div v-if="isMobile && showActions" class="mobile-floating-actions">
<el-button
v-for="action in mobileActions"
:key="action.name"
type="primary"
:icon="action.icon"
circle
@click="handleMobileAction(action)"
/>
</div>
</div>
<!-- 卡片视图 -->
<div v-else-if="viewMode === 'card'" class="responsive-card-view">
<div class="card-list">
<div
v-for="(item, index) in data"
:key="item.id || index"
:class="[
'data-card',
{
'data-card--selected': selectedRows.includes(item),
'data-card--striped': striped && index % 2 === 0
}
]"
@click="handleCardClick(item)"
>
<!-- 卡片头部 -->
<div class="card-header">
<div class="card-header-left">
<el-checkbox
v-if="selectable"
:model-value="selectedRows.includes(item)"
@click.stop="toggleRowSelection(item)"
/>
<div class="card-title">
<slot name="card-title" :row="item">
{{ getCardTitle(item) }}
</slot>
</div>
</div>
<div class="card-header-right">
<slot name="card-header-actions" :row="item" />
</div>
</div>
<!-- 卡片内容 -->
<div class="card-content">
<template v-for="column in visibleColumns" :key="column.prop">
<div v-if="shouldShowInCard(column)" class="card-field">
<span class="card-field__label">{{ column.label }}:</span>
<span class="card-field__value">
<slot v-if="column.slot" :name="column.slot" :row="item" />
<template v-else>
{{ column.formatter ? column.formatter(item) : item[column.prop] }}
</template>
</span>
</div>
</template>
</div>
<!-- 卡片操作 -->
<div v-if="showActions" class="card-actions">
<slot name="card-actions" :row="item">
<el-button-group>
<el-button
v-if="defaultActions?.view"
size="small"
@click.stop="handleView(item)"
>
查看
</el-button>
<el-button
v-if="defaultActions?.edit"
size="small"
type="primary"
@click.stop="handleEdit(item)"
>
编辑
</el-button>
<el-button
v-if="defaultActions?.delete"
size="small"
type="danger"
@click.stop="handleDelete(item)"
>
删除
</el-button>
</el-button-group>
</slot>
</div>
</div>
</div>
</div>
<!-- 分页器 -->
<div v-if="showPagination" class="responsive-table__pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="pageSizes"
:layout="paginationLayout"
:small="isMobile"
:background="true"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import {
ElTable,
ElTableColumn,
ElButton,
ElButtonGroup,
ElCheckbox,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElPagination
} from 'element-plus'
export default {
name: 'ResponsiveTable',
components: {
ElTable,
ElTableColumn,
ElButton,
ElButtonGroup,
ElCheckbox,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElPagination
},
props: {
// 数据相关
data: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => [],
required: true
},
// 显示配置
striped: {
type: Boolean,
default: false
},
bordered: {
type: Boolean,
default: false
},
hover: {
type: Boolean,
default: true
},
size: {
type: String,
default: 'default',
validator: (value) => ['mini', 'small', 'default', 'large'].includes(value)
},
height: [String, Number],
maxHeight: [String, Number],
scrollable: {
type: Boolean,
default: false
},
showHeader: {
type: Boolean,
default: true
},
highlightCurrentRow: {
type: Boolean,
default: false
},
// 功能配置
selectable: {
type: Boolean,
default: false
},
showIndex: {
type: Boolean,
default: false
},
indexLabel: {
type: String,
default: '序号'
},
showActions: {
type: Boolean,
default: false
},
actionLabel: {
type: String,
default: '操作'
},
defaultActions: {
type: Object,
default: null
},
// 工具栏配置
showToolbar: {
type: Boolean,
default: true
},
allowViewToggle: {
type: Boolean,
default: true
},
showColumnControl: {
type: Boolean,
default: true
},
// 卡片视图配置
cardView: {
type: Boolean,
default: false
},
cardTitleField: {
type: String,
default: 'title'
},
cardFields: {
type: Array,
default: () => []
},
// 分页配置
showPagination: {
type: Boolean,
default: false
},
total: {
type: Number,
default: 0
},
pageSizes: {
type: Array,
default: () => [10, 20, 50, 100]
},
paginationLayout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
},
// 空状态
emptyText: {
type: String,
default: ''
},
// 响应式配置
mobileBreakpoint: {
type: Number,
default: 768
},
tabletBreakpoint: {
type: Number,
default: 1024
},
autoSwitchView: {
type: Boolean,
default: true
}
},
emits: [
'row-click',
'selection-change',
'view',
'edit',
'delete',
'size-change',
'current-change',
'mobile-action'
],
setup(props, { emit }) {
// 响应式状态
const tableRef = ref(null)
const breakpoint = ref('desktop')
const viewMode = ref('table')
const selectedRows = ref([])
const columnVisibility = ref({})
const currentPage = ref(1)
const pageSize = ref(10)
// 响应式断点检测
const updateBreakpoint = () => {
const width = window.innerWidth
if (width < props.mobileBreakpoint) {
breakpoint.value = 'mobile'
if (props.autoSwitchView) {
viewMode.value = 'card'
}
} else if (width < props.tabletBreakpoint) {
breakpoint.value = 'tablet'
} else {
breakpoint.value = 'desktop'
if (props.autoSwitchView) {
viewMode.value = 'table'
}
}
}
// 设备类型计算属性
const isMobile = computed(() => breakpoint.value === 'mobile')
const isTablet = computed(() => breakpoint.value === 'tablet')
const isDesktop = computed(() => breakpoint.value === 'desktop')
// 列可见性管理
const visibleColumns = computed(() => {
return props.columns.filter(column => {
const isVisible = columnVisibility.value[column.prop] !== false
const shouldShow = column.hidden !== true && isVisible
// 移动端隐藏部分列
if (isMobile.value && column.hideOnMobile) {
return false
}
// 平板设备隐藏部分列
if (isTablet.value && column.hideOnTablet) {
return false
}
return shouldShow
})
})
// 初始化列可见性
const initColumnVisibility = () => {
props.columns.forEach(column => {
if (column.visible !== undefined) {
columnVisibility.value[column.prop] = column.visible
} else {
columnVisibility.value[column.prop] = true
}
})
}
// 计算列宽
const getColumnWidth = (column) => {
// 移动端优化
if (isMobile.value) {
if (column.mobileWidth) return column.mobileWidth
if (column.width) return Math.min(column.width, 120)
return null
}
// 平板优化
if (isTablet.value) {
if (column.tabletWidth) return column.tabletWidth
return column.width
}
return column.width
}
const getColumnMinWidth = (column) => {
if (isMobile.value) {
return column.mobileMinWidth || 80
}
if (isTablet.value) {
return column.tabletMinWidth || 100
}
return column.minWidth || 120
}
const getColumnFixed = (column) => {
if (isMobile.value && column.fixedOnMobile !== undefined) {
return column.fixedOnMobile
}
return column.fixed
}
// 操作列宽度计算
const getActionsWidth = computed(() => {
if (isMobile.value) {
const actionCount = props.defaultActions
? Object.keys(props.defaultActions).length
: 0
return Math.max(actionCount * 40, 80)
}
return 180
})
// 移动端操作按钮
const mobileActions = computed(() => {
if (!props.defaultActions) return []
const actions = []
if (props.defaultActions.view) {
actions.push({
name: 'view',
icon: 'el-icon-view',
handler: props.defaultActions.view.handler
})
}
if (props.defaultActions.edit) {
actions.push({
name: 'edit',
icon: 'el-icon-edit',
handler: props.defaultActions.edit.handler
})
}
if (props.defaultActions.delete) {
actions.push({
name: 'delete',
icon: 'el-icon-delete',
handler: props.defaultActions.delete.handler
})
}
return actions
})
// 表格尺寸计算
const computedSize = computed(() => {
if (isMobile.value) return 'small'
if (isTablet.value) return 'default'
return props.size
})
// 容器样式
const containerStyle = computed(() => {
const styles = {}
if (isMobile.value) {
styles.fontSize = '14px'
}
return styles
})
// 包装器样式
const wrapperStyle = computed(() => {
if (!props.scrollable) return {}
return {
overflowX: 'auto',
WebkitOverflowScrolling: 'touch'
}
})
// 卡片视图相关
const getCardTitle = (row) => {
if (props.cardTitleField && row[props.cardTitleField]) {
return row[props.cardTitleField]
}
return `项目 ${row.id || row._id}`
}
const shouldShowInCard = (column) => {
if (props.cardFields.length > 0) {
return props.cardFields.includes(column.prop)
}
// 默认显示前3列
const index = props.columns.findIndex(col => col.prop === column.prop)
return index < 3
}
// 事件处理
const handleColumnVisibilityChange = (column) => {
columnVisibility.value[column.prop] = column.visible
emit('column-visibility-change', column)
}
const handleRowClick = (row, column, event) => {
emit('row-click', row, column, event)
}
const handleCardClick = (row) => {
emit('row-click', row, null, null)
}
const handleSelectionChange = (selection) => {
selectedRows.value = selection
emit('selection-change', selection)
}
const toggleRowSelection = (row) => {
const index = selectedRows.value.findIndex(
selected => selected === row || selected.id === row.id
)
if (index > -1) {
selectedRows.value.splice(index, 1)
} else {
selectedRows.value.push(row)
}
emit('selection-change', selectedRows.value)
}
const handleView = (row) => {
emit('view', row)
}
const handleEdit = (row) => {
emit('edit', row)
}
const handleDelete = (row) => {
emit('delete', row)
}
const handleMobileAction = (action) => {
emit('mobile-action', action.name)
if (action.handler) {
action.handler()
}
}
const handleSizeChange = (size) => {
pageSize.value = size
emit('size-change', size)
}
const handleCurrentChange = (page) => {
currentPage.value = page
emit('current-change', page)
}
// 行类名
const rowClassName = ({ row, rowIndex }) => {
const classes = []
if (props.striped && rowIndex % 2 === 0) {
classes.push('striped-row')
}
if (selectedRows.value.includes(row)) {
classes.push('selected-row')
}
return classes.join(' ')
}
// 单元格类名
const cellClassName = ({ row, column, rowIndex, columnIndex }) => {
const classes = []
if (isMobile.value && column.property === 'selection') {
classes.push('mobile-selection-cell')
}
return classes.join(' ')
}
// 表头单元格类名
const headerCellClassName = ({ row, column, rowIndex, columnIndex }) => {
if (isMobile.value) {
return 'mobile-header-cell'
}
return ''
}
// 生命周期
onMounted(() => {
updateBreakpoint()
initColumnVisibility()
window.addEventListener('resize', updateBreakpoint)
// 添加触摸优化
if ('ontouchstart' in window) {
document.body.classList.add('touch-device')
}
})
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoint)
})
// 监听数据变化
watch(() => props.data, () => {
if (tableRef.value) {
tableRef.value.doLayout()
}
}, { deep: true })
// 监听断点变化
watch(breakpoint, (newVal) => {
console.log(`表格断点变化: ${newVal}`)
})
// 暴露方法
const clearSelection = () => {
if (tableRef.value) {
tableRef.value.clearSelection()
}
selectedRows.value = []
}
const toggleRowSelectionByIndex = (index) => {
if (tableRef.value) {
tableRef.value.toggleRowSelection(props.data[index])
}
}
return {
// refs
tableRef,
// 状态
breakpoint,
viewMode,
selectedRows,
columnVisibility,
currentPage,
pageSize,
// 计算属性
isMobile,
isTablet,
isDesktop,
visibleColumns,
getActionsWidth,
mobileActions,
computedSize,
containerStyle,
wrapperStyle,
// 方法
getColumnWidth,
getColumnMinWidth,
getColumnFixed,
getCardTitle,
shouldShowInCard,
handleColumnVisibilityChange,
handleRowClick,
handleCardClick,
handleSelectionChange,
toggleRowSelection,
handleView,
handleEdit,
handleDelete,
handleMobileAction,
handleSizeChange,
handleCurrentChange,
rowClassName,
cellClassName,
headerCellClassName,
clearSelection,
toggleRowSelectionByIndex
}
}
}
</script>
<style scoped>
.responsive-table-container {
position: relative;
background: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
transition: all 0.3s ease;
}
/* 工具栏样式 */
.responsive-table__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--el-border-color-light);
flex-wrap: wrap;
gap: 12px;
}
.responsive-table__toolbar-left,
.responsive-table__toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.view-toggle {
display: flex;
align-items: center;
}
/* 表格包装器 */
.responsive-table-wrapper {
position: relative;
overflow: hidden;
}
.responsive-table-wrapper--scrollable {
overflow-x: auto;
}
/* 移动端优化 */
@media (max-width: 767px) {
.responsive-table-container--mobile {
border-radius: 0;
margin: 0 -16px;
width: calc(100% + 32px);
}
.responsive-table__toolbar {
flex-direction: column;
align-items: stretch;
padding: 12px 16px;
}
.responsive-table__toolbar-left,
.responsive-table__toolbar-right {
width: 100%;
justify-content: space-between;
}
.el-table {
font-size: 14px;
}
.el-table__body-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.el-table th,
.el-table td {
padding: 8px 4px;
}
.mobile-header-cell {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.mobile-selection-cell {
padding: 8px 4px !important;
}
/* 移动端浮动操作按钮 */
.mobile-floating-actions {
position: fixed;
bottom: 80px;
right: 20px;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 1000;
}
.mobile-floating-actions .el-button {
width: 56px;
height: 56px;
font-size: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
/* 平板优化 */
@media (min-width: 768px) and (max-width: 1023px) {
.responsive-table-container--tablet {
margin: 0;
}
.el-table {
font-size: 14px;
}
.el-table th,
.el-table td {
padding: 10px 8px;
}
/* 隐藏不重要的列 */
.responsive-table-container--tablet :deep(.hidden-on-tablet) {
display: none;
}
}
/* 卡片视图样式 */
.responsive-card-view {
padding: 16px;
}
.card-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.data-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-base);
padding: 16px;
transition: all 0.3s ease;
cursor: pointer;
}
.data-card:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.data-card--selected {
border-color: var(--el-color-primary);
background: rgba(var(--el-color-primary-rgb), 0.05);
}
.data-card--striped {
background: var(--el-fill-color-light);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.card-header-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-content {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.card-field {
display: flex;
font-size: 14px;
line-height: 1.5;
}
.card-field__label {
color: var(--el-text-color-secondary);
min-width: 80px;
flex-shrink: 0;
}
.card-field__value {
color: var(--el-text-color-primary);
flex: 1;
min-width: 0;
word-break: break-word;
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
/* 空状态样式 */
.responsive-table__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
text-align: center;
}
.responsive-table__empty i {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
.responsive-table__empty p {
margin: 0;
font-size: 14px;
}
/* 分页器样式 */
.responsive-table__pagination {
display: flex;
justify-content: center;
padding: 20px 16px;
border-top: 1px solid var(--el-border-color-light);
}
/* 触摸设备优化 */
.touch-device .data-card {
min-height: 44px;
}
.touch-device .el-button {
min-height: 44px;
min-width: 44px;
}
/* 操作按钮容器 */
.responsive-table__actions {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
@media (max-width: 767px) {
.responsive-table__actions {
justify-content: center;
}
.responsive-table__actions .el-button {
padding: 4px 8px;
min-width: auto;
}
.responsive-table__actions .el-button span {
display: none;
}
}
/* 行样式增强 */
:deep(.striped-row) {
background-color: var(--el-fill-color-light) !important;
}
:deep(.selected-row) {
background-color: rgba(var(--el-color-primary-rgb), 0.1) !important;
}
:deep(.selected-row:hover > td) {
background-color: rgba(var(--el-color-primary-rgb), 0.15) !important;
}
/* 平滑滚动优化 */
.responsive-table-wrapper {
scrollbar-width: thin;
scrollbar-color: var(--el-border-color) transparent;
}
.responsive-table-wrapper::-webkit-scrollbar {
height: 8px;
width: 8px;
}
.responsive-table-wrapper::-webkit-scrollbar-track {
background: transparent;
}
.responsive-table-wrapper::-webkit-scrollbar-thumb {
background-color: var(--el-border-color);
border-radius: 4px;
}
.responsive-table-wrapper::-webkit-scrollbar-thumb:hover {
background-color: var(--el-border-color-dark);
}
</style>
3.1.3 使用示例与配置
vue
<!-- 使用响应式表格组件的示例 -->
<template>
<ResponsiveTable
:data="tableData"
:columns="columns"
:total="total"
:show-pagination="true"
:selectable="true"
:show-actions="true"
:default-actions="defaultActions"
@selection-change="handleSelectionChange"
@view="handleView"
@edit="handleEdit"
@delete="handleDelete"
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
>
<!-- 自定义状态列 -->
<template #status="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
<!-- 自定义操作 -->
<template #actions="{ row }">
<el-button
size="small"
type="primary"
@click="handleCustomAction(row)"
>
自定义操作
</el-button>
</template>
<!-- 工具栏左侧插槽 -->
<template #toolbar-left>
<el-button type="primary" @click="handleAdd">
新增
</el-button>
</template>
<!-- 空状态插槽 -->
<template #empty>
<div class="custom-empty">
<i class="el-icon-data-analysis" />
<p>暂无数据,点击上方按钮添加</p>
</div>
</template>
</ResponsiveTable>
</template>
<script>
import { ref, onMounted } from 'vue'
import ResponsiveTable from '@/components/table/ResponsiveTable.vue'
export default {
components: {
ResponsiveTable
},
setup() {
// 表格数据
const tableData = ref([])
const total = ref(0)
// 列配置
const columns = ref([
{
prop: 'id',
label: 'ID',
width: 80,
fixed: 'left',
hideOnMobile: true // 移动端隐藏ID列
},
{
prop: 'name',
label: '名称',
minWidth: 120,
fixedOnMobile: 'left' // 移动端固定
},
{
prop: 'category',
label: '分类',
width: 100,
hideOnTablet: true // 平板隐藏
},
{
prop: 'price',
label: '价格',
width: 100,
align: 'right',
formatter: (row) => `¥${row.price}`
},
{
prop: 'stock',
label: '库存',
width: 80,
align: 'center'
},
{
prop: 'status',
label: '状态',
width: 100,
slot: 'status', // 使用自定义插槽
hideOnMobile: true
},
{
prop: 'createdAt',
label: '创建时间',
width: 150,
hideOnMobile: true,
hideOnTablet: true
}
])
// 默认操作配置
const defaultActions = ref({
view: {
text: '查看',
title: '查看详情',
handler: (row) => handleView(row)
},
edit: {
text: '编辑',
title: '编辑项目',
handler: (row) => handleEdit(row)
},
delete: {
text: '删除',
title: '删除项目',
handler: (row) => handleDelete(row)
}
})
// 获取表格数据
const fetchTableData = async (page = 1, size = 10) => {
try {
// 模拟API调用
const response = await mockApi.fetchData(page, size)
tableData.value = response.data
total.value = response.total
} catch (error) {
console.error('获取数据失败:', error)
}
}
// 事件处理
const handleSelectionChange = (selection) => {
console.log('选中行:', selection)
}
const handleView = (row) => {
console.log('查看:', row)
}
const handleEdit = (row) => {
console.log('编辑:', row)
}
const handleDelete = (row) => {
console.log('删除:', row)
}
const handleAdd = () => {
console.log('新增项目')
}
const handleCustomAction = (row) => {
console.log('自定义操作:', row)
}
const handlePageSizeChange = (size) => {
console.log('每页大小:', size)
fetchTableData(1, size)
}
const handlePageChange = (page) => {
console.log('当前页:', page)
fetchTableData(page)
}
// 状态类型映射
const getStatusType = (status) => {
const typeMap = {
'active': 'success',
'inactive': 'info',
'pending': 'warning',
'rejected': 'danger'
}
return typeMap[status] || 'info'
}
onMounted(() => {
fetchTableData()
})
return {
tableData,
total,
columns,
defaultActions,
handleSelectionChange,
handleView,
handleEdit,
handleDelete,
handleAdd,
handleCustomAction,
handlePageSizeChange,
handlePageChange,
getStatusType
}
}
}
</script>
3.2 表单组件响应式优化
3.2.1 表单组件自适应实现
vue
<!-- src/components/form/ResponsiveForm.vue -->
<template>
<div
:class="[
'responsive-form',
{
'responsive-form--inline': layout === 'inline',
'responsive-form--vertical': layout === 'vertical',
'responsive-form--horizontal': layout === 'horizontal',
'responsive-form--disabled': disabled,
'responsive-form--loading': loading,
[`responsive-form--${breakpoint}`]: true,
[`responsive-form--label-${labelPosition}`]: labelPosition
}
]"
:style="formStyle"
>
<!-- 表单标题 -->
<div v-if="title || $slots.title" class="responsive-form__header">
<slot name="title">
<h3 class="responsive-form__title">{{ title }}</h3>
</slot>
</div>
<!-- 表单内容 -->
<div class="responsive-form__content">
<slot />
<!-- 自动生成的表单项 -->
<template v-if="fields && fields.length > 0">
<div
v-for="(field, index) in visibleFields"
:key="field.name || index"
:class="[
'form-field',
`form-field--${field.type || 'input'}`,
{
'form-field--required': field.required,
'form-field--hidden': field.hidden,
'form-field--full-width': field.span === 24 || getFieldSpan(field) === 24
}
]"
:style="getFieldStyle(field)"
>
<!-- 标签 -->
<label
v-if="field.label && showLabel"
:for="field.name"
:class="[
'form-field__label',
{
'form-field__label--required': field.required,
'form-field__label--top': labelPosition === 'top',
'form-field__label--left': labelPosition === 'left',
'form-field__label--right': labelPosition === 'right'
}
]"
:style="labelStyle"
>
{{ field.label }}
<span v-if="field.tooltip" class="form-field__tooltip">
<el-tooltip :content="field.tooltip" placement="top">
<i class="el-icon-question" />
</el-tooltip>
</span>
</label>
<!-- 表单项内容 -->
<div class="form-field__control">
<!-- 文本输入框 -->
<el-input
v-if="field.type === 'input'"
v-model="formData[field.name]"
:placeholder="field.placeholder || `请输入${field.label}`"
:clearable="field.clearable !== false"
:disabled="field.disabled || disabled"
:readonly="field.readonly"
:maxlength="field.maxlength"
:show-word-limit="field.showWordLimit"
:type="field.inputType || 'text'"
:size="fieldSize"
@change="handleFieldChange(field)"
@blur="handleFieldBlur(field)"
>
<!-- 前置内容 -->
<template v-if="field.prepend" #prepend>
<span v-if="typeof field.prepend === 'string'">{{ field.prepend }}</span>
<component v-else :is="field.prepend" />
</template>
<!-- 后置内容 -->
<template v-if="field.append" #append>
<span v-if="typeof field.append === 'string'">{{ field.append }}</span>
<component v-else :is="field.append" />
</template>
<!-- 前缀图标 -->
<template v-if="field.prefixIcon" #prefix>
<i :class="field.prefixIcon" />
</template>
<!-- 后缀图标 -->
<template v-if="field.suffixIcon" #suffix>
<i :class="field.suffixIcon" />
</template>
</el-input>
<!-- 数字输入框 -->
<el-input-number
v-else-if="field.type === 'number'"
v-model="formData[field.name]"
:min="field.min"
:max="field.max"
:step="field.step"
:precision="field.precision"
:controls="field.controls !== false"
:controls-position="field.controlsPosition"
:disabled="field.disabled || disabled"
:placeholder="field.placeholder"
:size="fieldSize"
@change="handleFieldChange(field)"
/>
<!-- 选择器 -->
<el-select
v-else-if="field.type === 'select'"
v-model="formData[field.name]"
:multiple="field.multiple"
:filterable="field.filterable"
:clearable="field.clearable !== false"
:disabled="field.disabled || disabled"
:placeholder="field.placeholder || `请选择${field.label}`"
:size="fieldSize"
@change="handleFieldChange(field)"
>
<el-option
v-for="option in getOptions(field)"
:key="option.value"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
/>
</el-select>
<!-- 日期选择器 -->
<el-date-picker
v-else-if="field.type === 'date'"
v-model="formData[field.name]"
:type="field.dateType || 'date'"
:format="field.format"
:value-format="field.valueFormat"
:clearable="field.clearable !== false"
:disabled="field.disabled || disabled"
:placeholder="field.placeholder"
:size="fieldSize"
@change="handleFieldChange(field)"
/>
<!-- 时间选择器 -->
<el-time-picker
v-else-if="field.type === 'time'"
v-model="formData[field.name]"
:format="field.format"
:value-format="field.valueFormat"
:clearable="field.clearable !== false"
:disabled="field.disabled || disabled"
:placeholder="field.placeholder"
:size="fieldSize"
@change="handleFieldChange(field)"
/>
<!-- 开关 -->
<el-switch
v-else-if="field.type === 'switch'"
v-model="formData[field.name]"
:active-text="field.activeText"
:inactive-text="field.inactiveText"
:active-value="field.activeValue !== undefined ? field.activeValue : true"
:inactive-value="field.inactiveValue !== undefined ? field.inactiveValue : false"
:disabled="field.disabled || disabled"
:size="fieldSize"
@change="handleFieldChange(field)"
/>
<!-- 单选框组 -->
<el-radio-group
v-else-if="field.type === 'radio'"
v-model="formData[field.name]"
:disabled="field.disabled || disabled"
:size="fieldSize"
@change="handleFieldChange(field)"
>
<el-radio
v-for="option in getOptions(field)"
:key="option.value"
:label="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</el-radio>
</el-radio-group>
<!-- 复选框组 -->
<el-checkbox-group
v-else-if="field.type === 'checkbox'"
v-model="formData[field.name]"
:disabled="field.disabled || disabled"
:size="fieldSize"
@change="handleFieldChange(field)"
>
<el-checkbox
v-for="option in getOptions(field)"
:key="option.value"
:label="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</el-checkbox>
</el-checkbox-group>
<!-- 滑块 -->
<el-slider
v-else-if="field.type === 'slider'"
v-model="formData[field.name]"
:min="field.min"
:max="field.max"
:step="field.step"
:show-stops="field.showStops"
:show-input="field.showInput"
:disabled="field.disabled || disabled"
:size="fieldSize"
@change="handleFieldChange(field)"
/>
<!-- 评分 -->
<el-rate
v-else-if="field.type === 'rate'"
v-model="formData[field.name]"
:max="field.max || 5"
:allow-half="field.allowHalf"
:show-text="field.showText"
:disabled="field.disabled || disabled"
:size="fieldSize"
@change="handleFieldChange(field)"
/>
<!-- 颜色选择器 -->
<el-color-picker
v-else-if="field.type === 'color'"
v-model="formData[field.name]"
:show-alpha="field.showAlpha"
:predefine="field.predefine"
:disabled="field.disabled || disabled"
:size="fieldSize"
@change="handleFieldChange(field)"
/>
<!-- 上传组件 -->
<el-upload
v-else-if="field.type === 'upload'"
v-model:file-list="formData[field.name]"
:action="field.action"
:multiple="field.multiple"
:limit="field.limit"
:accept="field.accept"
:list-type="field.listType || 'text'"
:disabled="field.disabled || disabled"
:size="fieldSize"
@change="handleFieldChange(field)"
>
<el-button :size="fieldSize" :disabled="field.disabled || disabled">
<i class="el-icon-upload" />
点击上传
</el-button>
</el-upload>
<!-- 自定义组件 -->
<component
v-else-if="field.component"
:is="field.component"
v-model="formData[field.name]"
v-bind="field.props"
:size="fieldSize"
:disabled="field.disabled || disabled"
@change="handleFieldChange(field)"
/>
<!-- 错误提示 -->
<div
v-if="fieldErrors[field.name]"
class="form-field__error"
>
{{ fieldErrors[field.name] }}
</div>
<!-- 帮助文本 -->
<div
v-if="field.help"
class="form-field__help"
>
{{ field.help }}
</div>
</div>
</div>
</template>
</div>
<!-- 表单操作 -->
<div v-if="showActions" class="responsive-form__actions">
<slot name="actions">
<el-button
type="primary"
:loading="loading"
:disabled="disabled"
@click="handleSubmit"
>
{{ submitText }}
</el-button>
<el-button
v-if="showReset"
@click="handleReset"
:disabled="loading || disabled"
>
{{ resetText }}
</el-button>
<el-button
v-if="showCancel"
@click="handleCancel"
:disabled="loading"
>
{{ cancelText }}
</el-button>
</slot>
</div>
</div>
</template>
<script>
import { ref, computed, watch, provide, onMounted, onUnmounted } from 'vue'
import {
ElInput,
ElInputNumber,
ElSelect,
ElOption,
ElDatePicker,
ElTimePicker,
ElSwitch,
ElRadioGroup,
ElRadio,
ElCheckboxGroup,
ElCheckbox,
ElSlider,
ElRate,
ElColorPicker,
ElUpload,
ElButton,
ElTooltip
} from 'element-plus'
export default {
name: 'ResponsiveForm',
components: {
ElInput,
ElInputNumber,
ElSelect,
ElOption,
ElDatePicker,
ElTimePicker,
ElSwitch,
ElRadioGroup,
ElRadio,
ElCheckboxGroup,
ElCheckbox,
ElSlider,
ElRate,
ElColorPicker,
ElUpload,
ElButton,
ElTooltip
},
props: {
// 表单配置
modelValue: {
type: Object,
default: () => ({})
},
fields: {
type: Array,
default: () => []
},
rules: {
type: Object,
default: () => ({})
},
// 布局配置
layout: {
type: String,
default: 'horizontal',
validator: (value) => ['horizontal', 'vertical', 'inline'].includes(value)
},
labelPosition: {
type: String,
default: 'right',
validator: (value) => ['left', 'right', 'top'].includes(value)
},
labelWidth: {
type: [String, Number],
default: 'auto'
},
showLabel: {
type: Boolean,
default: true
},
inline: {
type: Boolean,
default: false
},
span: {
type: Number,
default: 24
},
gutter: {
type: Number,
default: 16
},
// 显示配置
title: {
type: String,
default: ''
},
showActions: {
type: Boolean,
default: true
},
showReset: {
type: Boolean,
default: true
},
showCancel: {
type: Boolean,
default: false
},
submitText: {
type: String,
default: '提交'
},
resetText: {
type: String,
default: '重置'
},
cancelText: {
type: String,
default: '取消'
},
// 状态控制
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
},
// 响应式配置
responsive: {
type: Boolean,
default: true
},
mobileBreakpoint: {
type: Number,
default: 768
},
tabletBreakpoint: {
type: Number,
default: 1024
},
autoAdjustLayout: {
type: Boolean,
default: true
},
// 验证配置
validateOnChange: {
type: Boolean,
default: true
},
validateOnBlur: {
type: Boolean,
default: true
},
showValidationMessage: {
type: Boolean,
default: true
}
},
emits: [
'update:modelValue',
'submit',
'reset',
'cancel',
'change',
'validate',
'field-change',
'field-blur'
],
setup(props, { emit }) {
// 响应式状态
const breakpoint = ref('desktop')
const formData = ref({ ...props.modelValue })
const fieldErrors = ref({})
const isSubmitting = ref(false)
// 响应式断点检测
const updateBreakpoint = () => {
const width = window.innerWidth
if (width < props.mobileBreakpoint) {
breakpoint.value = 'mobile'
} else if (width < props.tabletBreakpoint) {
breakpoint.value = 'tablet'
} else {
breakpoint.value = 'desktop'
}
}
// 设备类型计算属性
const isMobile = computed(() => breakpoint.value === 'mobile')
const isTablet = computed(() => breakpoint.value === 'tablet')
const isDesktop = computed(() => breakpoint.value === 'desktop')
// 计算可见字段
const visibleFields = computed(() => {
return props.fields.filter(field => {
// 隐藏字段
if (field.hidden) return false
// 响应式隐藏
if (isMobile.value && field.hideOnMobile) return false
if (isTablet.value && field.hideOnTablet) return false
// 条件显示
if (field.showIf && typeof field.showIf === 'function') {
return field.showIf(formData.value)
}
return true
})
})
// 计算表单布局
const computedLayout = computed(() => {
if (!props.autoAdjustLayout) return props.layout
if (isMobile.value) return 'vertical'
if (isTablet.value) return 'vertical'
return props.layout
})
const computedLabelPosition = computed(() => {
if (!props.autoAdjustLayout) return props.labelPosition
if (isMobile.value) return 'top'
if (isTablet.value) return 'top'
return props.labelPosition
})
// 计算字段尺寸
const fieldSize = computed(() => {
if (isMobile.value) return 'small'
if (isTablet.value) return 'default'
return 'default'
})
// 计算标签宽度
const computedLabelWidth = computed(() => {
if (props.labelWidth === 'auto') {
if (isMobile.value) return '100%'
if (isTablet.value) return '120px'
return '150px'
}
return props.labelWidth
})
// 表单样式
const formStyle = computed(() => {
const styles = {}
if (props.inline) {
styles.display = 'flex'
styles.flexWrap = 'wrap'
styles.alignItems = 'flex-start'
styles.gap = `${props.gutter}px`
}
return styles
})
// 标签样式
const labelStyle = computed(() => {
if (computedLabelPosition.value !== 'left') return {}
return {
width: typeof computedLabelWidth.value === 'number'
? `${computedLabelWidth.value}px`
: computedLabelWidth.value,
textAlign: computedLabelPosition.value === 'left' ? 'right' : 'left'
}
})
// 计算字段跨度
const getFieldSpan = (field) => {
if (field.span) return field.span
// 响应式跨度
if (isMobile.value && field.mobileSpan) return field.mobileSpan
if (isTablet.value && field.tabletSpan) return field.tabletSpan
// 默认值
if (isMobile.value) return 24 // 移动端单列
if (isTablet.value) return field.tabletSpan || 12 // 平板端双列
return field.span || props.span
}
// 获取字段样式
const getFieldStyle = (field) => {
const span = getFieldSpan(field)
const isFullWidth = span === 24 || field.fullWidth
const styles = {}
if (props.inline) {
styles.flex = `0 0 calc(${(span / 24) * 100}% - ${props.gutter}px)`
styles.maxWidth = `calc(${(span / 24) * 100}% - ${props.gutter}px)`
} else if (computedLayout.value === 'horizontal') {
if (computedLabelPosition.value === 'left') {
styles.display = 'flex'
styles.alignItems = field.type === 'switch' ? 'center' : 'flex-start'
styles.marginBottom = '20px'
}
}
// 移动端优化
if (isMobile.value) {
styles.marginBottom = '16px'
}
return styles
}
// 获取选项列表
const getOptions = (field) => {
if (Array.isArray(field.options)) {
return field.options
}
if (typeof field.options === 'function') {
return field.options(formData.value)
}
return []
}
// 验证表单
const validateForm = async () => {
const errors = {}
let isValid = true
// 验证每个字段
for (const field of visibleFields.value) {
if (field.rules || props.rules[field.name]) {
const rules = field.rules || props.rules[field.name]
const value = formData.value[field.name]
for (const rule of Array.isArray(rules) ? rules : [rules]) {
// 必填验证
if (rule.required && (value === undefined || value === null || value === '')) {
errors[field.name] = rule.message || `${field.label}不能为空`
isValid = false
break
}
// 正则验证
if (rule.pattern && !rule.pattern.test(value)) {
errors[field.name] = rule.message || `${field.label}格式不正确`
isValid = false
break
}
// 自定义验证函数
if (rule.validator && typeof rule.validator === 'function') {
const result = rule.validator(value, formData.value)
if (result === false || typeof result === 'string') {
errors[field.name] = result || rule.message || `${field.label}验证失败`
isValid = false
break
}
}
}
}
}
fieldErrors.value = errors
emit('validate', { isValid, errors })
return { isValid, errors }
}
// 验证单个字段
const validateField = (field) => {
if (!props.validateOnChange) return
const rules = field.rules || props.rules[field.name]
if (!rules) return
const value = formData.value[field.name]
const fieldRules = Array.isArray(rules) ? rules : [rules]
for (const rule of fieldRules) {
// 必填验证
if (rule.required && (value === undefined || value === null || value === '')) {
fieldErrors.value[field.name] = rule.message || `${field.label}不能为空`
return false
}
// 正则验证
if (rule.pattern && !rule.pattern.test(value)) {
fieldErrors.value[field.name] = rule.message || `${field.label}格式不正确`
return false
}
// 自定义验证
if (rule.validator && typeof rule.validator === 'function') {
const result = rule.validator(value, formData.value)
if (result === false || typeof result === 'string') {
fieldErrors.value[field.name] = result || rule.message || `${field.label}验证失败`
return false
}
}
}
// 验证通过,清除错误
delete fieldErrors.value[field.name]
return true
}
// 事件处理
const handleFieldChange = (field) => {
emit('field-change', { field, value: formData.value[field.name] })
emit('change', formData.value)
emit('update:modelValue', formData.value)
// 触发验证
if (props.validateOnChange) {
validateField(field)
}
}
const handleFieldBlur = (field) => {
emit('field-blur', { field, value: formData.value[field.name] })
// 触发验证
if (props.validateOnBlur) {
validateField(field)
}
}
const handleSubmit = async () => {
// 验证表单
const { isValid } = await validateForm()
if (!isValid) {
return
}
isSubmitting.value = true
try {
await emit('submit', formData.value)
} finally {
isSubmitting.value = false
}
}
const handleReset = () => {
formData.value = { ...props.modelValue }
fieldErrors.value = {}
emit('reset', formData.value)
}
const handleCancel = () => {
emit('cancel')
}
// 初始化表单数据
const initFormData = () => {
const initialData = { ...props.modelValue }
// 为每个字段设置初始值
props.fields.forEach(field => {
if (field.name && initialData[field.name] === undefined) {
initialData[field.name] = field.defaultValue !== undefined
? field.defaultValue
: null
}
})
formData.value = initialData
}
// 监听数据变化
watch(() => props.modelValue, (newValue) => {
formData.value = { ...newValue }
}, { deep: true })
// 提供表单上下文
provide('formContext', {
formData,
fieldErrors,
disabled: computed(() => props.disabled),
readonly: computed(() => props.readonly),
loading: computed(() => props.loading || isSubmitting.value),
validateField,
getFieldSpan
})
// 生命周期
onMounted(() => {
updateBreakpoint()
initFormData()
window.addEventListener('resize', updateBreakpoint)
})
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoint)
})
return {
// 状态
breakpoint,
formData,
fieldErrors,
// 计算属性
isMobile,
isTablet,
isDesktop,
visibleFields,
computedLayout,
computedLabelPosition,
fieldSize,
computedLabelWidth,
formStyle,
labelStyle,
// 方法
getFieldSpan,
getFieldStyle,
getOptions,
handleFieldChange,
handleFieldBlur,
handleSubmit,
handleReset,
handleCancel
}
}
}
</script>
<style scoped>
.responsive-form {
width: 100%;
box-sizing: border-box;
}
/* 表单头部 */
.responsive-form__header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-light);
}
.responsive-form__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
/* 表单内容 */
.responsive-form__content {
margin-bottom: 24px;
}
/* 表单项样式 */
.form-field {
position: relative;
margin-bottom: 20px;
}
.form-field--hidden {
display: none !important;
}
.form-field--full-width {
width: 100%;
}
/* 标签样式 */
.form-field__label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
line-height: 1.5;
}
.form-field__label--required::before {
content: '*';
color: var(--el-color-danger);
margin-right: 4px;
}
.form-field__label--top {
display: block;
}
.form-field__label--left {
display: inline-block;
margin-right: 12px;
text-align: right;
vertical-align: middle;
line-height: 32px;
}
.form-field__label--right {
display: inline-block;
margin-left: 12px;
text-align: left;
vertical-align: middle;
line-height: 32px;
}
.form-field__tooltip {
margin-left: 4px;
color: var(--el-text-color-secondary);
cursor: help;
}
/* 表单控件容器 */
.form-field__control {
flex: 1;
min-width: 0;
}
/* 错误提示 */
.form-field__error {
margin-top: 4px;
font-size: 12px;
color: var(--el-color-danger);
line-height: 1.5;
}
/* 帮助文本 */
.form-field__help {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
/* 表单操作 */
.responsive-form__actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 20px;
border-top: 1px solid var(--el-border-color-light);
}
/* 移动端优化 */
@media (max-width: 767px) {
.responsive-form--mobile {
padding: 16px;
}
.responsive-form__header {
margin-bottom: 20px;
padding-bottom: 12px;
}
.responsive-form__title {
font-size: 16px;
}
.form-field {
margin-bottom: 16px;
}
.form-field__label {
font-size: 13px;
margin-bottom: 6px;
}
.responsive-form__actions {
flex-direction: column;
gap: 8px;
}
.responsive-form__actions .el-button {
width: 100%;
margin: 0;
}
/* 移动端触摸优化 */
.el-input,
.el-select,
.el-date-picker,
.el-time-picker {
--el-component-size: 44px;
}
.el-input__inner,
.el-select__wrapper,
.el-date-picker__editor {
height: 44px !important;
min-height: 44px !important;
line-height: 44px !important;
}
.el-button {
min-height: 44px !important;
padding: 12px 20px !important;
}
.el-radio,
.el-checkbox {
min-height: 44px !important;
line-height: 44px !important;
}
.el-radio__inner,
.el-checkbox__inner {
transform: scale(1.2);
}
}
/* 平板优化 */
@media (min-width: 768px) and (max-width: 1023px) {
.responsive-form--tablet {
padding: 24px;
}
.responsive-form__title {
font-size: 17px;
}
/* 平板端双列布局 */
.responsive-form__content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.form-field {
margin-bottom: 0;
}
.form-field--full-width {
grid-column: 1 / -1;
}
/* 水平布局调整 */
.responsive-form--horizontal .form-field {
display: flex;
align-items: center;
}
.responsive-form--horizontal .form-field__label {
margin-bottom: 0;
margin-right: 12px;
width: 120px;
text-align: right;
flex-shrink: 0;
}
.responsive-form--horizontal .form-field__control {
flex: 1;
}
}
/* 桌面端优化 */
@media (min-width: 1024px) {
.responsive-form--desktop {
padding: 32px;
}
.responsive-form__title {
font-size: 20px;
}
/* 桌面端多列布局 */
.responsive-form__content {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.form-field {
margin-bottom: 0;
}
.form-field--full-width {
grid-column: 1 / -1;
}
/* 水平布局优化 */
.responsive-form--horizontal .form-field {
display: flex;
align-items: flex-start;
}
.responsive-form--horizontal .form-field__label {
margin-bottom: 0;
margin-right: 16px;
width: 150px;
text-align: right;
flex-shrink: 0;
line-height: 32px;
}
.responsive-form--horizontal .form-field__control {
flex: 1;
}
}
/* 行内表单优化 */
.responsive-form--inline {
padding: 0;
border: none;
}
.responsive-form--inline .form-field {
margin-bottom: 0;
}
/* 垂直表单优化 */
.responsive-form--vertical .form-field {
display: block;
}
.responsive-form--vertical .form-field__label {
display: block;
text-align: left;
width: 100%;
}
/* 禁用状态 */
.responsive-form--disabled .form-field__control :deep(*) {
opacity: 0.6;
cursor: not-allowed;
}
/* 加载状态 */
.responsive-form--loading {
position: relative;
}
.responsive-form--loading::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.7);
z-index: 10;
pointer-events: none;
}
/* 触摸设备优化 */
@media (hover: none) and (pointer: coarse) {
.responsive-form {
--touch-target-size: 44px;
}
/* 增大触摸目标 */
.el-button,
[role="button"] {
min-height: var(--touch-target-size) !important;
min-width: var(--touch-target-size) !important;
}
/* 优化输入框 */
input,
textarea,
select {
font-size: 16px !important;
min-height: var(--touch-target-size) !important;
}
/* 移除 hover 效果 */
.el-button:hover {
background-color: inherit !important;
color: inherit !important;
}
/* 用 active 状态替代 */
.el-button:active {
opacity: 0.8;
transform: scale(0.98);
}
}
/* 平滑过渡 */
.responsive-form,
.form-field,
.form-field__label,
.form-field__control {
transition: all 0.3s ease;
}
</style>