10 KiB
10 KiB
018-responsive.mdc (Deep Reference)
该文件为原始详细规范归档,供 Tier 3 按需读取。
📱 Responsive Design & Multi-Device Standards
断点体系(与 Tailwind 统一)
⚠️ 双前端区分:本文件中的 Element Plus 移动端适配内容仅适用于管理端 (
Case-Database-Frontend-admin/)。 用户端 (Case-Database-Frontend-user/) 使用 Headless UI + Tailwind CSS,禁止引入 Element Plus。
| 断点 | Tailwind 前缀 | 最小宽度 | 目标设备 |
|---|---|---|---|
| 默认 | (无前缀) | 0px | 手机竖屏 (< 640px) |
| sm | sm: |
640px | 手机横屏 / 小平板 |
| md | md: |
768px | 平板竖屏 (iPad) |
| lg | lg: |
1024px | 平板横屏 / 小屏笔记本 |
| xl | xl: |
1280px | 桌面 |
| 2xl | 2xl: |
1536px | 大屏桌面 / 4K |
原则:移动优先(Mobile-First)— 先写手机样式,用断点向上覆盖。
<!-- ✅ 移动优先:默认手机,逐步增强 -->
<div class="p-4 md:p-6 xl:p-8 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<!-- 手机 1列 → 平板 2列 → 桌面 3列 -->
</div>
<!-- ❌ 桌面优先(禁用):max-* 限制往下适配 -->
<div class="grid grid-cols-3 max-md:grid-cols-1">...</div>
布局组件响应式模式
侧边栏布局(后台管理系统标准模式)
<!-- src/layouts/AppLayout.vue -->
<template>
<div class="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
<!-- 遮罩层:移动端打开侧边栏时显示 -->
<Transition name="fade">
<div
v-if="isMobile && sidebarOpen"
class="fixed inset-0 z-20 bg-black/50"
@click="sidebarOpen = false"
/>
</Transition>
<!-- 侧边栏:桌面固定,移动端抽屉式 -->
<aside
:class="[
'fixed lg:relative z-30 h-full transition-transform duration-300',
'w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700',
isMobile && !sidebarOpen ? '-translate-x-full' : 'translate-x-0',
]"
>
<ArtSidebarMenu />
</aside>
<!-- 主内容区 -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<!-- 顶部导航 -->
<header class="h-14 md:h-16 flex items-center px-4 md:px-6 border-b bg-white dark:bg-gray-800">
<!-- 移动端:汉堡菜单按钮 -->
<button
class="lg:hidden mr-3 p-2 rounded-lg text-gray-500 hover:bg-gray-100"
aria-label="打开菜单"
@click="sidebarOpen = !sidebarOpen"
>
<el-icon :size="20"><Menu /></el-icon>
</button>
<ArtHeader />
</header>
<!-- 页面内容 -->
<main class="flex-1 overflow-auto">
<div class="p-4 md:p-6 xl:p-8 max-w-screen-2xl mx-auto">
<router-view />
</div>
</main>
</div>
</div>
</template>
<script setup>
const { isMobile } = useDevice()
const sidebarOpen = ref(false)
// 路由变化时关闭移动端侧边栏
const route = useRoute()
watch(() => route.path, () => {
if (isMobile.value) sidebarOpen.value = false
})
</script>
useDevice Composable
// src/composables/useDevice.ts
export function useDevice() {
const width = ref(window.innerWidth)
const handleResize = useDebounceFn(() => {
width.value = window.innerWidth
}, 100)
onMounted(() => window.addEventListener('resize', handleResize))
onUnmounted(() => window.removeEventListener('resize', handleResize))
return {
width: readonly(width),
isMobile: computed(() => width.value < 768),
isTablet: computed(() => width.value >= 768 && width.value < 1024),
isDesktop: computed(() => width.value >= 1024),
isTouch: computed(() => 'ontouchstart' in window),
}
}
Element Plus 移动端适配(仅管理端)
组件尺寸策略
<script setup>
const { isMobile } = useDevice()
const elSize = computed(() => (isMobile.value ? 'small' : 'default'))
</script>
<template>
<!-- ✅ 根据设备自适应 size -->
<el-form :size="elSize">
<el-input :size="elSize" />
<el-button :size="elSize" type="primary">提交</el-button>
</el-form>
<!-- ✅ 表格移动端简化列 -->
<el-table :data="tableData">
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column v-if="!isMobile" prop="createdAt" label="创建时间" width="160" />
<el-table-column v-if="!isMobile" prop="status" label="状态" width="100" />
<!-- 移动端合并展示 -->
<el-table-column v-if="isMobile" label="详情" min-width="200">
<template #default="{ row }">
<div class="text-sm">{{ row.status }} · {{ row.createdAt }}</div>
</template>
</el-table-column>
</el-table>
</template>
对话框适配
<!-- ✅ 移动端全屏对话框 -->
<el-dialog
v-model="dialogVisible"
:fullscreen="isMobile"
:width="isMobile ? '100%' : '600px'"
:class="{ 'rounded-t-2xl': isMobile }"
>
分页适配
<!-- ✅ 移动端简化分页 -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
:pager-count="isMobile ? 3 : 7"
:page-sizes="isMobile ? [10, 20] : [10, 20, 50, 100]"
:total="total"
/>
表单布局
<!-- ✅ 移动端垂直布局,桌面水平布局 -->
<el-form
:label-position="isMobile ? 'top' : 'right'"
:label-width="isMobile ? 'auto' : '100px'"
>
触摸与手势优化
最小触摸目标尺寸
// src/assets/styles/touch.scss
// 根据 WCAG 2.5.5,触摸目标最小 44×44px
.touch-target {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
@media (hover: none) and (pointer: coarse) {
// 触屏设备:增大可点击区域
.el-button {
min-height: 44px;
padding-left: 16px;
padding-right: 16px;
}
.el-input__wrapper {
min-height: 44px;
}
}
滑动手势(移动端列表操作)
// src/composables/useSwipeAction.ts
export function useSwipeAction(onDelete: () => void, onEdit: () => void) {
const startX = ref(0)
const offsetX = ref(0)
const THRESHOLD = 80
function onTouchStart(e: TouchEvent) {
startX.value = e.touches[0].clientX
}
function onTouchMove(e: TouchEvent) {
const diff = e.touches[0].clientX - startX.value
offsetX.value = Math.max(-160, Math.min(0, diff)) // 最多滑动 160px
}
function onTouchEnd() {
if (offsetX.value < -THRESHOLD) {
// 滑过阈值:显示操作按钮
} else {
offsetX.value = 0 // 弹回
}
}
return { offsetX, onTouchStart, onTouchMove, onTouchEnd }
}
图片与媒体响应式
<template>
<!-- ✅ 响应式图片:不同分辨率加载不同尺寸 -->
<picture>
<source media="(min-width: 1280px)" srcset="/img/banner-xl.webp" />
<source media="(min-width: 768px)" srcset="/img/banner-md.webp" />
<img src="/img/banner-sm.webp" alt="Banner" class="w-full h-auto object-cover" loading="lazy" />
</picture>
<!-- ✅ 使用 Intersection Observer 懒加载 -->
<img
v-lazy="imageUrl"
alt="产品图片"
class="w-full aspect-square object-cover rounded-lg"
:class="{ 'animate-pulse bg-gray-200': !isLoaded }"
/>
</template>
字体与文字排版
// 流式字体大小:随屏幕宽度线性变化
:root {
--font-base: clamp(14px, 1vw + 12px, 16px);
--font-heading: clamp(20px, 3vw + 10px, 36px);
}
body { font-size: var(--font-base); }
h1 { font-size: var(--font-heading); }
// 行高:移动端更宽松(小屏阅读舒适度)
p {
line-height: 1.6;
@media (max-width: 767px) {
line-height: 1.8;
}
}
安全区域(刘海屏 / 全面屏)
/* 底部安全区域(iOS 全面屏 Home Bar)*/
.bottom-nav {
padding-bottom: env(safe-area-inset-bottom, 16px);
}
/* 顶部安全区域(刘海屏)*/
.top-bar {
padding-top: env(safe-area-inset-top, 0px);
}
// tailwind.config.ts — 添加安全区域工具类
theme: {
extend: {
padding: {
'safe-bottom': 'env(safe-area-inset-bottom, 16px)',
'safe-top': 'env(safe-area-inset-top, 0px)',
},
},
},
响应式测试矩阵
| 设备 | 分辨率 | 断点 | 关键功能检查 |
|---|---|---|---|
| iPhone SE (3代) | 375×667 | xs | 侧边栏抽屉、表单垂直布局 |
| iPhone 14 Pro | 393×852 | xs | 安全区域、全面屏底部导航 |
| iPhone 14 Pro Max | 430×932 | sm | 横屏布局、键盘遮挡处理 |
| iPad (10代) | 820×1180 | md | 平板双栏、对话框宽度 |
| iPad Pro 12.9" | 1024×1366 | lg | 分页组件、表格完整列 |
| MacBook Air 13" | 1280×800 | xl | 完整侧边栏、桌面表格 |
| 4K 显示器 | 1920×1080 | 2xl | 最大宽度限制、留白 |
测试脚本(Playwright)
// tests/responsive.spec.ts
import { test, expect } from '@playwright/test'
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 },
]
for (const vp of viewports) {
test(`dashboard layout on ${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.goto('/dashboard')
if (vp.name === 'mobile') {
// 移动端:侧边栏默认隐藏
await expect(page.locator('aside')).toHaveCSS('transform', 'matrix(1, 0, 0, 1, -256, 0)')
// 汉堡按钮可见
await expect(page.locator('[aria-label="打开菜单"]')).toBeVisible()
} else {
// 平板/桌面:侧边栏默认显示
await expect(page.locator('aside')).toBeVisible()
}
})
}
规则
- 所有新页面必须在 375px / 768px / 1280px 三个断点下验证
- 禁止使用
px设置字体大小(用rem或 Tailwind 文本类) - 触摸目标最小 44×44px
- 禁止使用
:hover作为唯一交互反馈(移动端无 hover) - 管理端:所有
el-dialog必须处理移动端fullscreen属性(用户端使用 Headless UI Dialog) - 图片必须设置
loading="lazy"和aspect-ratio(防止布局抖动 CLS) - 横屏(landscape)模式下的布局需专门测试