1162 lines
25 KiB
Markdown
1162 lines
25 KiB
Markdown
# GameGroup 移动端与跨平台方案
|
||
|
||
**项目名称**: GameGroup 前端系统
|
||
**文档版本**: v1.0
|
||
**更新时间**: 2026-01-28
|
||
|
||
---
|
||
|
||
## 📋 目录
|
||
|
||
- [1. 需求分析](#1-需求分析)
|
||
- [2. 技术方案选型](#2-技术方案选型)
|
||
- [3. 移动端增强设计](#3-移动端增强设计)
|
||
- [4. 跨平台移植方案](#4-跨平台移植方案)
|
||
- [5. 性能优化](#5-性能优化)
|
||
- [6. 最佳实践](#6-最佳实践)
|
||
|
||
---
|
||
|
||
## 1. 需求分析
|
||
|
||
### 1.1 核心需求
|
||
|
||
- **流畅运行**: 移动端60fps流畅体验
|
||
- **美观易用**: 符合移动端设计规范
|
||
- **简单移植**: 最小化代码改动,实现多平台覆盖
|
||
- **原生体验**: 接近原生App的交互体验
|
||
|
||
### 1.2 目标平台
|
||
|
||
| 平台 | 优先级 | 覆盖方式 |
|
||
|------|--------|----------|
|
||
| iOS App | 高 | Capacitor / PWA |
|
||
| Android App | 高 | Capacitor / PWA |
|
||
| 微信小程序 | 中 | uni-app / 原生小程序 |
|
||
| Web | 高 | 响应式Web应用 |
|
||
|
||
---
|
||
|
||
## 2. 技术方案选型
|
||
|
||
### 2.1 推荐方案:Capacitor + PWA
|
||
|
||
#### 方案对比
|
||
|
||
| 方案 | 优势 | 劣势 | 推荐度 |
|
||
|------|------|------|--------|
|
||
| **Capacitor** | • 原生性能<br>• 可访问原生功能<br>• App Store分发<br>• 代码复用率高 | • 需要打包发布<br>• 审核周期 | ⭐⭐⭐⭐⭐ |
|
||
| **PWA** | • 无需审核<br>• 即时更新<br>• 可安装到桌面<br>• 渐进式增强 | • iOS支持有限<br>• 无法访问所有原生API | ⭐⭐⭐⭐ |
|
||
| **React Native** | • 原生组件<br>• 性能最优 | • 需要重写<br>• 无法复用代码 | ⭐⭐ |
|
||
| **uni-app** | • 一套代码多端运行<br>• 小程序友好 | • Vue 2支持为主<br>• 性能损耗 | ⭐⭐⭐ |
|
||
|
||
#### 最终方案:**Capacitor + PWA**
|
||
|
||
**理由**:
|
||
1. ✅ 保持Vue 3 + TypeScript技术栈
|
||
2. ✅ 代码复用率95%以上
|
||
3. ✅ 可发布到App Store和Google Play
|
||
4. ✅ 支持PWA,无需审核即可使用
|
||
5. ✅ 可访问摄像头、推送通知等原生功能
|
||
6. ✅ 团队学习成本低
|
||
|
||
---
|
||
|
||
## 3. 移动端增强设计
|
||
|
||
### 3.1 导航模式
|
||
|
||
#### 移动端专属导航
|
||
|
||
```
|
||
移动端布局(< 768px)
|
||
┌─────────────────────────────┐
|
||
│ [≡] GameGroup [🔔][👤] │ ← 顶部栏
|
||
├─────────────────────────────┤
|
||
│ │
|
||
│ 内容区域 │
|
||
│ (可滚动) │
|
||
│ │
|
||
│ │
|
||
├─────────────────────────────┤
|
||
│ [首页][小组][游戏][我的] │ ← 底部Tab导航
|
||
└─────────────────────────────┘
|
||
```
|
||
|
||
#### 实现代码
|
||
|
||
```vue
|
||
<!-- components/layout/MobileLayout.vue -->
|
||
<template>
|
||
<div class="mobile-layout">
|
||
<!-- 顶部栏 -->
|
||
<div class="mobile-header">
|
||
<Button
|
||
icon="menu"
|
||
type="text"
|
||
@click="toggleSidebar"
|
||
/>
|
||
<h1 class="logo">GameGroup</h1>
|
||
<div class="header-actions">
|
||
<Icon name="bell" :badge="unreadCount" />
|
||
<Avatar :src="user.avatar" size="small" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区 -->
|
||
<div class="mobile-content">
|
||
<RouterView v-slot="{ Component }">
|
||
<Transition name="slide" mode="out-in">
|
||
<component :is="Component" />
|
||
</Transition>
|
||
</RouterView>
|
||
</div>
|
||
|
||
<!-- 底部Tab导航 -->
|
||
<div class="mobile-tabs">
|
||
<div
|
||
v-for="tab in tabs"
|
||
:key="tab.path"
|
||
:class="['tab-item', { active: isActive(tab.path) }]"
|
||
@click="navigate(tab.path)"
|
||
>
|
||
<Icon :name="tab.icon" />
|
||
<span>{{ tab.label }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 侧边栏抽屉 -->
|
||
<Drawer v-model:visible="sidebarVisible" placement="left">
|
||
<Sidebar />
|
||
</Drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const sidebarVisible = ref(false)
|
||
|
||
const tabs = [
|
||
{ path: '/', icon: 'home', label: '首页' },
|
||
{ path: '/groups', icon: 'users', label: '小组' },
|
||
{ path: '/games', icon: 'game', label: '游戏' },
|
||
{ path: '/user', icon: 'user', label: '我的' }
|
||
]
|
||
|
||
const isActive = (path: string) => route.path.startsWith(path)
|
||
|
||
const navigate = (path: string) => {
|
||
router.push(path)
|
||
}
|
||
|
||
const toggleSidebar = () => {
|
||
sidebarVisible.value = true
|
||
}
|
||
|
||
const unreadCount = computed(() => 0) // 从store获取
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.mobile-layout {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
background: var(--color-bg-primary);
|
||
}
|
||
|
||
.mobile-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
background: var(--color-bg-primary);
|
||
border-bottom: 1px solid var(--color-border);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}
|
||
|
||
.logo {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--color-primary-500);
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.mobile-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
}
|
||
|
||
.mobile-tabs {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
|
||
background: var(--color-bg-primary);
|
||
border-top: 1px solid var(--color-border);
|
||
position: sticky;
|
||
bottom: 0;
|
||
}
|
||
|
||
.tab-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 8px 16px;
|
||
color: var(--color-text-secondary);
|
||
transition: color 0.2s;
|
||
|
||
&.active {
|
||
color: var(--color-primary-500);
|
||
}
|
||
|
||
svg {
|
||
width: 24px;
|
||
height: 24px;
|
||
}
|
||
|
||
span {
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
// 页面切换动画
|
||
.slide-enter-active,
|
||
.slide-leave-active {
|
||
transition: transform 0.3s, opacity 0.3s;
|
||
}
|
||
|
||
.slide-enter-from {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
|
||
.slide-leave-to {
|
||
transform: translateX(-30%);
|
||
opacity: 0;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### 3.2 触摸优化
|
||
|
||
#### 按钮尺寸
|
||
|
||
移动端最小触摸目标:**44px × 44px**(iOS人机界面指南)
|
||
|
||
```css
|
||
/* 移动端按钮尺寸 */
|
||
@media (max-width: 768px) {
|
||
.g-button {
|
||
min-height: 44px;
|
||
min-width: 44px;
|
||
padding: 12px 20px;
|
||
font-size: 16px; /* 防止iOS自动缩放 */
|
||
}
|
||
|
||
.g-button--small {
|
||
min-height: 36px;
|
||
padding: 8px 16px;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 手势支持
|
||
|
||
```typescript
|
||
// composables/useSwipe.ts
|
||
import { ref, onMounted, onUnmounted } from 'vue'
|
||
|
||
export function useSwipe(
|
||
elementRef: Ref<HTMLElement | undefined>,
|
||
options: {
|
||
onSwipeLeft?: () => void
|
||
onSwipeRight?: () => void
|
||
onSwipeUp?: () => void
|
||
onSwipeDown?: () => void
|
||
threshold?: number
|
||
} = {}
|
||
) {
|
||
let startX = 0
|
||
let startY = 0
|
||
const threshold = options.threshold || 50
|
||
|
||
const onTouchStart = (e: TouchEvent) => {
|
||
startX = e.touches[0].clientX
|
||
startY = e.touches[0].clientY
|
||
}
|
||
|
||
const onTouchEnd = (e: TouchEvent) => {
|
||
const endX = e.changedTouches[0].clientX
|
||
const endY = e.changedTouches[0].clientY
|
||
const diffX = endX - startX
|
||
const diffY = endY - startY
|
||
|
||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
||
// 水平滑动
|
||
if (Math.abs(diffX) > threshold) {
|
||
if (diffX > 0) {
|
||
options.onSwipeRight?.()
|
||
} else {
|
||
options.onSwipeLeft?.()
|
||
}
|
||
}
|
||
} else {
|
||
// 垂直滑动
|
||
if (Math.abs(diffY) > threshold) {
|
||
if (diffY > 0) {
|
||
options.onSwipeDown?.()
|
||
} else {
|
||
options.onSwipeUp?.()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
const el = elementRef.value
|
||
if (el) {
|
||
el.addEventListener('touchstart', onTouchStart)
|
||
el.addEventListener('touchend', onTouchEnd)
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
const el = elementRef.value
|
||
if (el) {
|
||
el.removeEventListener('touchstart', onTouchStart)
|
||
el.removeEventListener('touchend', onTouchEnd)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 使用示例
|
||
const cardRef = ref<HTMLElement>()
|
||
|
||
useSwipe(cardRef, {
|
||
onSwipeLeft: () => {
|
||
// 左滑删除
|
||
deleteItem(item.id)
|
||
},
|
||
threshold: 80
|
||
})
|
||
```
|
||
|
||
#### 下拉刷新
|
||
|
||
```vue
|
||
<template>
|
||
<div
|
||
ref="containerRef"
|
||
class="pull-refresh"
|
||
@touchstart="onTouchStart"
|
||
@touchmove="onTouchMove"
|
||
@touchend="onTouchEnd"
|
||
>
|
||
<div class="pull-indicator" :style="{ transform: `translateY(${status}px)` }">
|
||
<Icon v-if="loading" name="loading" class="is-spin" />
|
||
<span v-else>{{ pullText }}</span>
|
||
</div>
|
||
<slot />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
|
||
const startY = ref(0)
|
||
const currentY = ref(0)
|
||
const loading = ref(false)
|
||
const containerRef = ref<HTMLElement>()
|
||
|
||
const status = computed(() => {
|
||
if (loading.value) return 50
|
||
return Math.max(0, currentY.value - startY.value)
|
||
})
|
||
|
||
const pullText = computed(() => {
|
||
const distance = status.value
|
||
if (distance < 50) return '下拉刷新'
|
||
if (loading.value) return '正在刷新...'
|
||
return '释放立即刷新'
|
||
})
|
||
|
||
const onTouchStart = (e: TouchEvent) => {
|
||
startY.value = e.touches[0].clientY
|
||
}
|
||
|
||
const onTouchMove = (e: TouchEvent) => {
|
||
currentY.value = e.touches[0].clientY
|
||
}
|
||
|
||
const onTouchEnd = async () => {
|
||
if (status.value >= 50 && !loading.value) {
|
||
loading.value = true
|
||
await emit('refresh')
|
||
loading.value = false
|
||
}
|
||
startY.value = 0
|
||
currentY.value = 0
|
||
}
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
### 3.3 移动端专属组件
|
||
|
||
#### 底部动作面板(ActionSheet)
|
||
|
||
```vue
|
||
<template>
|
||
<Transition name="slide-up">
|
||
<div v-if="visible" class="action-sheet" @click="handleMaskClick">
|
||
<div class="action-sheet__content" @click.stop>
|
||
<div class="action-sheet__title">{{ title }}</div>
|
||
<div class="action-sheet__actions">
|
||
<div
|
||
v-for="(action, index) in actions"
|
||
:key="index"
|
||
:class="['action-item', action.className]"
|
||
@click="handleAction(action)"
|
||
>
|
||
{{ action.label }}
|
||
</div>
|
||
</div>
|
||
<div class="action-item action-item--cancel" @click="handleCancel">
|
||
取消
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</template>
|
||
```
|
||
|
||
#### 移动端卡片列表
|
||
|
||
```vue
|
||
<template>
|
||
<div class="mobile-card-list">
|
||
<div
|
||
v-for="item in items"
|
||
:key="item.id"
|
||
class="mobile-card"
|
||
@click="handleClick(item)"
|
||
>
|
||
<img :src="item.cover" class="card-cover" />
|
||
<div class="card-content">
|
||
<h4 class="card-title">{{ item.title }}</h4>
|
||
<p class="card-desc">{{ item.description }}</p>
|
||
<div class="card-meta">
|
||
<Tag size="small">{{ item.tag }}</Tag>
|
||
<span class="card-time">{{ formatTime(item.time) }}</span>
|
||
</div>
|
||
</div>
|
||
<Icon name="chevron-right" class="card-arrow" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.mobile-card {
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
background: white;
|
||
border-radius: 12px;
|
||
margin-bottom: 12px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||
|
||
&:active {
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
}
|
||
|
||
.card-cover {
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin: 0 0 4px 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-desc {
|
||
font-size: 14px;
|
||
color: var(--color-text-secondary);
|
||
margin: 0 0 8px 0;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.card-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.card-arrow {
|
||
flex-shrink: 0;
|
||
color: var(--color-text-tertiary);
|
||
align-self: center;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 跨平台移植方案
|
||
|
||
### 4.1 Capacitor集成
|
||
|
||
#### 安装Capacitor
|
||
|
||
```bash
|
||
# 安装Capacitor
|
||
npm install @capacitor/core @capacitor/cli
|
||
|
||
# 初始化
|
||
npx cap init GameGroup com.gamegroup.app
|
||
|
||
# 安装平台
|
||
npm install @capacitor/android @capacitor/ios
|
||
|
||
# 构建
|
||
npm run build
|
||
|
||
# 同步代码
|
||
npx cap sync
|
||
|
||
# 打开Android Studio
|
||
npx cap open android
|
||
|
||
# 打开Xcode
|
||
npx cap open ios
|
||
```
|
||
|
||
#### 配置文件
|
||
|
||
```xml
|
||
<!-- capacitor.config.xml -->
|
||
<capacitor-config>
|
||
<appName>GameGroup</appName>
|
||
<appId>com.gamegroup.app</appId>
|
||
<webDir>dist</webDir>
|
||
<server>
|
||
<androidScheme>https</androidScheme>
|
||
<iosScheme>https</iosScheme>
|
||
</server>
|
||
|
||
<!-- iOS配置 -->
|
||
<ios>
|
||
<minVersion>13.0</minVersion>
|
||
<preferences>
|
||
<preference name="WKWebViewDecelerationSpeed" value="fast" />
|
||
</preferences>
|
||
</ios>
|
||
|
||
<!-- Android配置 -->
|
||
<android>
|
||
<minVersion>24</minVersion>
|
||
</android>
|
||
|
||
<!-- 插件 -->
|
||
<plugins>
|
||
<plugin id="Camera" />
|
||
<plugin id="LocalNotifications" />
|
||
<plugin id="PushNotifications" />
|
||
<plugin id="Share" />
|
||
<plugin id="Haptics" />
|
||
<plugin id="StatusBar" />
|
||
<plugin id="Keyboard" />
|
||
</plugins>
|
||
</capacitor-config>
|
||
```
|
||
|
||
#### 使用原生功能
|
||
|
||
```typescript
|
||
// 相机
|
||
import { Camera, CameraResultType } from '@capacitor/camera'
|
||
|
||
async function takePicture() {
|
||
const image = await Camera.getPhoto({
|
||
quality: 90,
|
||
allowEditing: true,
|
||
resultType: CameraResultType.Uri
|
||
})
|
||
return image.webPath
|
||
}
|
||
|
||
// 推送通知
|
||
import { PushNotifications } from '@capacitor/push-notifications'
|
||
|
||
async function initPushNotifications() {
|
||
const result = await PushNotifications.register()
|
||
console.log('Registered:', result)
|
||
}
|
||
|
||
// 分享
|
||
import { Share } from '@capacitor/share'
|
||
|
||
async function shareContent(title: string, text: string, url: string) {
|
||
await Share.share({
|
||
title,
|
||
text,
|
||
url
|
||
})
|
||
}
|
||
|
||
// 触觉反馈
|
||
import { Haptics, ImpactStyle } from '@capacitor/haptics'
|
||
|
||
async function hapticImpact() {
|
||
await Haptics.impact({ style: ImpactStyle.Medium })
|
||
}
|
||
|
||
// 状态栏
|
||
import { StatusBar, Style } from '@capacitor/status-bar'
|
||
|
||
async function setStatusBar() {
|
||
await StatusBar.setStyle({ style: Style.Light })
|
||
await StatusBar.setBackgroundColor({ color: '#8b5cf6' })
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.2 PWA配置
|
||
|
||
#### Manifest文件
|
||
|
||
```json
|
||
{
|
||
"name": "GameGroup",
|
||
"short_name": "GameGroup",
|
||
"description": "游戏社群管理平台",
|
||
"start_url": "/",
|
||
"display": "standalone",
|
||
"background_color": "#ffffff",
|
||
"theme_color": "#8b5cf6",
|
||
"orientation": "portrait",
|
||
"icons": [
|
||
{
|
||
"src": "/icons/icon-72x72.png",
|
||
"sizes": "72x72",
|
||
"type": "image/png",
|
||
"purpose": "any"
|
||
},
|
||
{
|
||
"src": "/icons/icon-96x96.png",
|
||
"sizes": "96x96",
|
||
"type": "image/png",
|
||
"purpose": "any"
|
||
},
|
||
{
|
||
"src": "/icons/icon-128x128.png",
|
||
"sizes": "128x128",
|
||
"type": "image/png",
|
||
"purpose": "any"
|
||
},
|
||
{
|
||
"src": "/icons/icon-144x144.png",
|
||
"sizes": "144x144",
|
||
"type": "image/png",
|
||
"purpose": "any"
|
||
},
|
||
{
|
||
"src": "/icons/icon-152x152.png",
|
||
"sizes": "152x152",
|
||
"type": "image/png",
|
||
"purpose": "any"
|
||
},
|
||
{
|
||
"src": "/icons/icon-192x192.png",
|
||
"sizes": "192x192",
|
||
"type": "image/png",
|
||
"purpose": "any"
|
||
},
|
||
{
|
||
"src": "/icons/icon-384x384.png",
|
||
"sizes": "384x384",
|
||
"type": "image/png",
|
||
"purpose": "any"
|
||
},
|
||
{
|
||
"src": "/icons/icon-512x512.png",
|
||
"sizes": "512x512",
|
||
"type": "image/png",
|
||
"purpose": "any"
|
||
},
|
||
{
|
||
"src": "/icons/maskable-icon-512x512.png",
|
||
"sizes": "512x512",
|
||
"type": "image/png",
|
||
"purpose": "maskable"
|
||
}
|
||
],
|
||
"screenshots": [
|
||
{
|
||
"src": "/screenshots/mobile-home.png",
|
||
"sizes": "390x844",
|
||
"type": "image/png",
|
||
"form_factor": "narrow"
|
||
}
|
||
],
|
||
"categories": ["social", "games"],
|
||
"shortcuts": [
|
||
{
|
||
"name": "创建预约",
|
||
"short_name": "预约",
|
||
"description": "快速创建游戏预约",
|
||
"url": "/appointments/create",
|
||
"icons": [{ "src": "/icons/appointment.png", "sizes": "96x96" }]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### Service Worker
|
||
|
||
```typescript
|
||
// src/sw.ts
|
||
import { registerRoute, NavigationRoute, CacheFirst, StaleWhileRevalidate } from 'workbox-routing'
|
||
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
|
||
import { ExpirationPlugin } from 'workbox-expiration'
|
||
import { precacheAndRoute } from 'workbox-precaching'
|
||
|
||
// 预缓存静态资源
|
||
precacheAndRoute(self.__WB_MANIFEST)
|
||
|
||
// 缓存图片
|
||
registerRoute(
|
||
({ request }) => request.destination === 'image',
|
||
new CacheFirst({
|
||
cacheName: 'images',
|
||
plugins: [
|
||
new CacheableResponsePlugin({
|
||
statuses: [0, 200]
|
||
}),
|
||
new ExpirationPlugin({
|
||
maxEntries: 60,
|
||
maxAgeSeconds: 30 * 24 * 60 * 60 // 30天
|
||
})
|
||
]
|
||
})
|
||
)
|
||
|
||
// 缓存API响应
|
||
registerRoute(
|
||
({ url }) => url.pathname.startsWith('/api'),
|
||
new StaleWhileRevalidate({
|
||
cacheName: 'api-responses',
|
||
plugins: [
|
||
new CacheableResponsePlugin({
|
||
statuses: [0, 200]
|
||
}),
|
||
new ExpirationPlugin({
|
||
maxEntries: 50,
|
||
maxAgeSeconds: 5 * 60 // 5分钟
|
||
})
|
||
]
|
||
})
|
||
)
|
||
|
||
// 离线页面
|
||
registerRoute(
|
||
new NavigationRoute(
|
||
new CacheFirst({
|
||
cacheName: 'offline-page',
|
||
plugins: [
|
||
new CacheableResponsePlugin({
|
||
statuses: [0, 200]
|
||
})
|
||
]
|
||
})
|
||
)
|
||
)
|
||
```
|
||
|
||
#### 注册Service Worker
|
||
|
||
```typescript
|
||
// src/main.ts
|
||
import { registerSW } from 'virtual:pwa-register'
|
||
|
||
if (import.meta.env.PROD) {
|
||
registerSW({
|
||
onNeedRefresh() {
|
||
// 显示更新提示
|
||
showUpdateNotification()
|
||
},
|
||
onOfflineReady() {
|
||
// 显示离线就绪提示
|
||
showOfflineReadyNotification()
|
||
}
|
||
})
|
||
}
|
||
```
|
||
|
||
#### Vite PWA插件配置
|
||
|
||
```typescript
|
||
// vite.config.ts
|
||
import { VitePWA } from 'vite-plugin-pwa'
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
VitePWA({
|
||
registerType: 'autoUpdate',
|
||
workbox: {
|
||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}']
|
||
},
|
||
manifest: {
|
||
name: 'GameGroup',
|
||
short_name: 'GameGroup',
|
||
theme_color: '#8b5cf6',
|
||
icons: [
|
||
{
|
||
src: '/icons/icon-192x192.png',
|
||
sizes: '192x192',
|
||
type: 'image/png'
|
||
},
|
||
{
|
||
src: '/icons/icon-512x512.png',
|
||
sizes: '512x512',
|
||
type: 'image/png'
|
||
}
|
||
]
|
||
}
|
||
})
|
||
]
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 性能优化
|
||
|
||
### 5.1 移动端性能优化
|
||
|
||
#### 减少首屏加载
|
||
|
||
```typescript
|
||
// 路由懒加载
|
||
const routes = [
|
||
{
|
||
path: '/',
|
||
component: () => import('@/views/home/index.vue')
|
||
},
|
||
{
|
||
path: '/groups',
|
||
component: () => import('@/views/group/List.vue')
|
||
}
|
||
]
|
||
|
||
// 组件懒加载
|
||
const HeavyChart = defineAsyncComponent(() =>
|
||
import('@/components/HeavyChart.vue')
|
||
)
|
||
```
|
||
|
||
#### 图片优化
|
||
|
||
```vue
|
||
<template>
|
||
<!-- 响应式图片 -->
|
||
<picture>
|
||
<source
|
||
media="(max-width: 640px)"
|
||
:srcset="image.mobile"
|
||
/>
|
||
<source
|
||
media="(min-width: 641px)"
|
||
:srcset="image.desktop"
|
||
/>
|
||
<img
|
||
:src="image.fallback"
|
||
:alt="image.alt"
|
||
loading="lazy"
|
||
/>
|
||
</picture>
|
||
|
||
<!-- 或使用Vue组件 -->
|
||
<ResponsiveImage
|
||
:mobile="image.mobile"
|
||
:desktop="image.desktop"
|
||
:alt="image.alt"
|
||
lazy
|
||
/>
|
||
</template>
|
||
```
|
||
|
||
#### 虚拟滚动
|
||
|
||
```vue
|
||
<template>
|
||
<RecycleScroller
|
||
:items="longList"
|
||
:item-size="80"
|
||
key-field="id"
|
||
v-slot="{ item }"
|
||
>
|
||
<div class="list-item">{{ item.name }}</div>
|
||
</RecycleScroller>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
### 5.2 网络优化
|
||
|
||
#### 离线优先策略
|
||
|
||
```typescript
|
||
// composables/useOffline.ts
|
||
import { ref, onMounted, onUnmounted } from 'vue'
|
||
|
||
export function useOffline() {
|
||
const isOnline = ref(navigator.onLine)
|
||
|
||
const updateStatus = () => {
|
||
isOnline.value = navigator.onLine
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('online', updateStatus)
|
||
window.addEventListener('offline', updateStatus)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('online', updateStatus)
|
||
window.removeEventListener('offline', updateStatus)
|
||
})
|
||
|
||
return { isOnline }
|
||
}
|
||
|
||
// 使用
|
||
const { isOnline } = useOffline()
|
||
|
||
watchEffect(() => {
|
||
if (!isOnline.value) {
|
||
showToast('当前处于离线模式')
|
||
}
|
||
})
|
||
```
|
||
|
||
#### 数据预加载
|
||
|
||
```typescript
|
||
// router/guards.ts
|
||
router.beforeEach(async (to, from, next) => {
|
||
// 预加载可能访问的页面数据
|
||
if (from.path === '/groups' && to.path === '/groups/:id') {
|
||
const groupStore = useGroupStore()
|
||
await groupStore.fetchGroupDetail(to.params.id)
|
||
}
|
||
|
||
next()
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 最佳实践
|
||
|
||
### 6.1 安全区域适配
|
||
|
||
```css
|
||
/* 安全区域padding */
|
||
.safe-area {
|
||
padding-top: env(safe-area-inset-top);
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
padding-left: env(safe-area-inset-left);
|
||
padding-right: env(safe-area-inset-right);
|
||
}
|
||
|
||
/* 固定顶部栏 */
|
||
.fixed-header {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding-top: calc(12px + env(safe-area-inset-top));
|
||
}
|
||
|
||
/* 固定底部栏 */
|
||
.fixed-footer {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||
}
|
||
```
|
||
|
||
### 6.2 视口配置
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta
|
||
name="viewport"
|
||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
|
||
/>
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||
<meta name="theme-color" content="#8b5cf6" />
|
||
</head>
|
||
</html>
|
||
```
|
||
|
||
### 6.3 检测移动端
|
||
|
||
```typescript
|
||
// utils/device.ts
|
||
export const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||
navigator.userAgent
|
||
)
|
||
|
||
export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||
|
||
export const isAndroid = /Android/.test(navigator.userAgent)
|
||
|
||
export const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||
|
||
// 获取安全区域
|
||
export function getSafeAreaInset() {
|
||
const style = getComputedStyle(document.documentElement)
|
||
return {
|
||
top: parseInt(style.getPropertyValue('safe-area-inset-top')),
|
||
right: parseInt(style.getPropertyValue('safe-area-inset-right')),
|
||
bottom: parseInt(style.getPropertyValue('safe-area-inset-bottom')),
|
||
left: parseInt(style.getPropertyValue('safe-area-inset-left'))
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.4 性能监控
|
||
|
||
```typescript
|
||
// utils/performance.ts
|
||
export function measureRender(componentName: string) {
|
||
const markStart = `${componentName}-start`
|
||
const markEnd = `${componentName}-end`
|
||
|
||
performance.mark(markStart)
|
||
|
||
return () => {
|
||
performance.mark(markEnd)
|
||
performance.measure(
|
||
componentName,
|
||
markStart,
|
||
markEnd
|
||
)
|
||
|
||
const measure = performance.getEntriesByName(componentName)[0]
|
||
console.log(`${componentName} render time: ${measure.duration}ms`)
|
||
}
|
||
}
|
||
|
||
// 使用
|
||
onMounted(() => {
|
||
const endMeasure = measureRender('HomePage')
|
||
// 组件渲染完成后
|
||
nextTick(() => {
|
||
endMeasure()
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 总结
|
||
|
||
### 7.1 方案优势
|
||
|
||
| 特性 | Capacitor | PWA | Web |
|
||
|------|-----------|-----|-----|
|
||
| 原生性能 | ✅ | ⚠️ | ⚠️ |
|
||
| App Store分发 | ✅ | ❌ | ❌ |
|
||
| 即时更新 | ⚠️ | ✅ | ✅ |
|
||
| 原生功能 | ✅ | ⚠️ | ❌ |
|
||
| 代码复用 | 95% | 100% | 100% |
|
||
|
||
### 7.2 实施建议
|
||
|
||
**阶段1: Web端优先(2-3周)**
|
||
- ✅ 实现响应式布局
|
||
- ✅ 优化移动端体验
|
||
- ✅ 配置PWA
|
||
|
||
**阶段2: Capacitor集成(1-2周)**
|
||
- ✅ 安装Capacitor
|
||
- ✅ 配置iOS和Android
|
||
- ✅ 集成原生功能(推送、分享等)
|
||
|
||
**阶段3: 打包发布(1周)**
|
||
- ✅ 准备应用图标和截图
|
||
- ✅ 配置应用签名
|
||
- ✅ 提交App Store和Google Play
|
||
|
||
### 7.3 注意事项
|
||
|
||
1. **iOS限制**
|
||
- PWA在iOS上功能受限
|
||
- 推荐使用Capacitor打包
|
||
- 需要Apple Developer账号($99/年)
|
||
|
||
2. **Android限制**
|
||
- 需要应用签名
|
||
- Google Play一次性费用($25)
|
||
- PWA可直接安装
|
||
|
||
3. **性能建议**
|
||
- 使用虚拟滚动处理长列表
|
||
- 图片懒加载和响应式图片
|
||
- 代码分割和懒加载
|
||
- 启用生产模式压缩
|
||
|
||
---
|
||
|
||
**文档维护**: 随移动端开发持续更新
|
||
**最后更新**: 2026-01-28
|
||
**技术负责**: Frontend Team
|