Files
gamegroup2/docs/superpowers/plans/2026-06-15-mobile-phase1-infrastructure.md
T

847 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 手机端阶段 1:基础设施 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 搭建手机端基础设施——引入 Vant 组件库、设备检测、路由分流、MobileLayout(底部 Tab + 顶部栏)、主题适配,使手机浏览器访问能自动进入手机版框架页。
**Architecture:** 在现有 Vue 3 项目内新增手机端视图层,路由层根据设备检测结果动态 import 桌面或手机视图。Vant 全量引入(与现有 ElementPlus 全量引入风格一致),主题 CSS 变量映射到现有 `--gg-*` 设计系统。本阶段不改任何桌面端代码,仅新增文件 + 改造路由与入口。
**Tech Stack:** Vue 3 + TypeScript + Vite + Vue Router 4 + Vant 4(新增)+ 现有 PocketBase SDK / Pinia(复用)
**Spec 参考:** `docs/superpowers/specs/2026-06-15-mobile-frontend-design.md` 第 2-5 节
**项目约定(执行前必读):**
- 构建走 Docker**不要**本地启动 vite dev server。测试通过 `deploy-dev.sh` 部署后访问 `http://192.168.1.14:7033`
- Windows 环境,命令行用 cmd 语法
- 现有 `main.ts` 全量引入 ElementPlus,手机端 Vant 同样全量引入保持一致
- 提交用 `git -c user.name="zcode" -c user.email="zcode@local" commit`
---
## 文件结构(本阶段涉及)
**新增:**
- `frontend/src/mobile/useDevice.ts` — 设备检测 composableUA + 屏宽 + localStorage
- `frontend/src/mobile/MobileLayout.vue` — 手机端主布局(顶部栏 + 底部 Tab + router-view
- `frontend/src/assets/mobile.css` — Vant 主题变量覆盖(映射到 `--gg-*`
- `frontend/src/views-mobile/Placeholder.vue` — 占位页(未实现的页面暂用)
**修改:**
- `frontend/package.json` — 新增 vant 依赖
- `frontend/src/main.ts` — 注册 Vant + 引入 mobile.css
- `frontend/src/router/index.ts` — 路由分流(设备检测 + 动态 import)
- `frontend/src/App.vue` — viewport meta 适配(已在 index.html 处理,本阶段确认)
**不改:**
- 所有 `frontend/src/views/*``frontend/src/components/*`(桌面端零改动)
- `frontend/src/api/*``frontend/src/stores/*``frontend/src/types/*``frontend/src/composables/*`(共享层零改动)
- `frontend/src/assets/design.css`(设计系统零改动,手机端只在其之上叠加 mobile.css
---
## Task 1: 安装 Vant 依赖
**Files:**
- Modify: `frontend/package.json`
- [ ] **Step 1: 进入 frontend 目录安装 vant**
Run(在 `D:\ZcodeProj\FT\frontend` 下):
```cmd
npm install vant@^4
```
Expected: package.json 的 dependencies 出现 `"vant": "^4.x.x"`,生成/更新 package-lock.json
- [ ] **Step 2: 验证安装成功**
Run:
```cmd
node -e "console.log(require('vant/package.json').version)"
```
Expected: 输出 4.x.x 版本号,无报错
- [ ] **Step 3: 提交**
```cmd
git add frontend/package.json frontend/package-lock.json
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add vant 4 dependency"
```
---
## Task 2: 创建设备检测 composable
**Files:**
- Create: `frontend/src/mobile/useDevice.ts`
- [ ] **Step 1: 创建 useDevice.ts**
创建 `frontend/src/mobile/useDevice.ts`
```ts
// src/mobile/useDevice.ts
// 设备检测:判断当前是否移动端,结果存 localStorage 避免重复检测
const STORAGE_KEY = 'device_mode'
/** UA 关键字匹配移动设备 */
const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i
/** 屏幕宽度阈值(含平板竖屏) */
const MOBILE_WIDTH = 768
/**
* 原始检测:综合 UA 和屏幕宽度判断
* - UA 命中移动设备关键字 → 手机
* - 屏宽 <= 768 → 手机
* - 否则 → 桌面
*/
function detectRaw(): 'mobile' | 'desktop' {
if (typeof navigator === 'undefined') return 'desktop'
if (MOBILE_UA.test(navigator.userAgent)) return 'mobile'
if (typeof window !== 'undefined' && window.innerWidth <= MOBILE_WIDTH) return 'mobile'
return 'desktop'
}
/** 当前设备模式(读取 localStorage,无则检测并存入) */
export function getDeviceMode(): 'mobile' | 'desktop' {
if (typeof localStorage === 'undefined') return detectRaw()
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'mobile' || stored === 'desktop') return stored
const detected = detectRaw()
localStorage.setItem(STORAGE_KEY, detected)
return detected
}
/** 是否移动端(路由分流用,同步函数) */
export function isMobile(): boolean {
return getDeviceMode() === 'mobile'
}
/**
* 手动切换设备模式(设置页"切换到桌面版/手机版"用)
* 切换后需整页刷新以重新走路由解析
*/
export function setDeviceMode(mode: 'mobile' | 'desktop') {
localStorage.setItem(STORAGE_KEY, mode)
}
/** 重置为自动检测(登出时调用,避免下个用户沿用上个用户的偏好) */
export function resetDeviceMode() {
localStorage.removeItem(STORAGE_KEY)
}
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/mobile/useDevice.ts
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add device detection composable"
```
---
## Task 3: 创建 Vant 主题覆盖 CSS
**Files:**
- Create: `frontend/src/assets/mobile.css`
- [ ] **Step 1: 创建 mobile.css**
创建 `frontend/src/assets/mobile.css`,将 Vant 的 CSS 变量映射到现有 `--gg-*` 设计系统(参考 `frontend/src/assets/design.css` 的变量定义):
```css
/* src/assets/mobile.css */
/* Vant 4 主题变量覆盖 —— 映射到项目 design.css 的 --gg-* 设计系统 */
:root {
/* 主色(Vant primary → gg-primary 绿色) */
--van-primary-color: var(--gg-primary); /* #059669 */
--van-success-color: var(--gg-success); /* #10b981 */
--van-danger-color: var(--gg-danger); /* #ef4444 */
--van-warning-color: var(--gg-warning); /* #f59e0b */
/* 文字色 */
--van-text-color: var(--gg-text); /* #1e293b */
--van-text-color-2: var(--gg-text-secondary); /* #475569 */
--van-text-color-3: var(--gg-text-muted); /* #94a3b8 */
/* 背景 */
--van-background: var(--gg-bg); /* #f0fdf4 */
--van-background-2: var(--gg-bg-card); /* #ffffff */
/* 边框 */
--van-border-color: var(--gg-border); /* #e2e8f0 */
/* 圆角 */
--van-radius-sm: var(--gg-radius-sm);
--van-radius-md: var(--gg-radius-md);
--van-radius-lg: var(--gg-radius-lg);
/* Tabbar(底部导航) */
--van-tabbar-background: var(--gg-bg-card);
--van-tabbar-item-active-color: var(--gg-primary);
--van-tabbar-item-text-color: var(--gg-text-muted);
--van-tabbar-height: 56px;
/* NavBar(顶部栏) */
--van-nav-bar-background: var(--gg-bg-card);
--van-nav-bar-title-font-size: 17px;
--van-nav-bar-height: 52px;
--van-nav-bar-icon-color: var(--gg-text);
/* 按钮主色渐变 */
--van-button-primary-background: var(--gg-primary);
--van-button-primary-border-color: var(--gg-primary);
}
/* 手机端全局:安全区域适配(刘海屏底部 Tab 不被遮挡) */
.mobile-app {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* 禁止移动端点击高亮 */
.mobile-app * {
-webkit-tap-highlight-color: transparent;
}
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/assets/mobile.css
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add vant theme override css"
```
---
## Task 4: 注册 Vant 并引入主题 CSS
**Files:**
- Modify: `frontend/src/main.ts`
- [ ] **Step 1: 修改 main.ts**
`frontend/src/main.ts` 改为(在现有 ElementPlus 之后注册 Vant):
```ts
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Vant from 'vant'
import 'vant/lib/index.css'
import './assets/design.css'
import './assets/mobile.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.use(Vant)
app.mount('#app')
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/main.ts
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): register vant and import theme css"
```
---
## Task 5: 创建占位页(未实现的手机页面暂用)
**Files:**
- Create: `frontend/src/views-mobile/Placeholder.vue`
- [ ] **Step 1: 创建占位组件**
创建 `frontend/src/views-mobile/Placeholder.vue`
```vue
<!-- src/views-mobile/Placeholder.vue -->
<!-- 占位页手机端尚未实现的页面暂时显示此组件 -->
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<template>
<div class="placeholder-page">
<van-empty description="该页面手机版开发中">
<p class="placeholder-hint">当前页面{{ route.name }}</p>
</van-empty>
</div>
</template>
<style scoped>
.placeholder-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 24px;
}
.placeholder-hint {
margin-top: 8px;
font-size: 12px;
color: var(--gg-text-muted);
}
</style>
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/views-mobile/Placeholder.vue
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add placeholder view for unimplemented pages"
```
---
## Task 6: 创建 MobileLayout 主布局
**Files:**
- Create: `frontend/src/mobile/MobileLayout.vue`
- [ ] **Step 1: 创建 MobileLayout.vue**
创建 `frontend/src/mobile/MobileLayout.vue`。这是手机端主布局:顶部栏(返回 + 标题 + 通知铃铛)+ 底部 5 Tab + 内容区。复用现有 `useUserStore``useGroupStore``useTeamStore``useNotificationStore`(与桌面 Layout.vue 相同的初始化逻辑)。
底部 Tab 对应路由:首页 `/`、群组 `/mobile-groups`、游戏 `/games`、通知 `/mobile-notifications`、我的 `/profile`。其中群组和通知在后续阶段才真正实现,本阶段先指向占位页。
```vue
<!-- src/mobile/MobileLayout.vue -->
<!-- 手机端主布局顶部栏 + 底部 Tab + 内容区 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { useNotificationStore } from '@/stores/notification'
import { displayName } from '@/types'
import { resetDeviceMode } from '@/mobile/useDevice'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const notificationStore = useNotificationStore()
// 与桌面 Layout 相同的初始化
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await userStore.initUser()
await groupStore.loadGroups()
await teamStore.loadActiveSession()
await notificationStore.loadPendingInvitations()
await notificationStore.startListening()
refreshTimer = setInterval(async () => {
await groupStore.loadGroups()
await teamStore.loadActiveSession()
}, 30000)
})
onUnmounted(() => {
notificationStore.stopListening()
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
// 当前激活的底部 Tab(根据路由匹配)
const activeTab = computed(() => {
if (route.path.startsWith('/mobile-groups')) return 'groups'
if (route.path.startsWith('/games')) return 'games'
if (route.path.startsWith('/mobile-notifications')) return 'notifications'
if (route.path.startsWith('/profile')) return 'profile'
return 'home'
})
// 顶部栏标题
const pageTitle = computed(() => {
const titles: Record<string, string> = {
home: 'Game Group',
groups: '我的群组',
games: '游戏库',
notifications: '通知',
profile: '我的'
}
// 群组详情页显示群组名
if (route.name === 'GroupView' && groupStore.currentGroup) {
return groupStore.currentGroup.name
}
return titles[activeTab.value] || 'Game Group'
})
// 顶部栏是否显示返回按钮(非 Tab 根页面显示)
const showBack = computed(() => {
return !['home', 'groups', 'games', 'notifications', 'profile'].includes(activeTab.value)
})
// 底部 Tab 切换
function onTabChange(name: string) {
const routes: Record<string, string> = {
home: '/',
groups: '/mobile-groups',
games: '/games',
notifications: '/mobile-notifications',
profile: '/profile'
}
const target = routes[name]
if (target && route.path !== target) {
router.push(target)
}
}
// 返回上一页
function onBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
// 通知未读数
const unreadCount = computed(() => notificationStore.unreadCount)
function goNotifications() {
router.push('/mobile-notifications')
}
// 退出登录
function handleLogout() {
resetDeviceMode()
userStore.logout()
router.push({ name: 'Login' })
}
</script>
<template>
<div class="mobile-app">
<!-- 顶部栏 -->
<van-nav-bar
:title="pageTitle"
:left-arrow="showBack"
fixed
placeholder
@click-left="onBack"
>
<template #right>
<van-badge :content="unreadCount > 0 ? unreadCount : ''" :show-zero="false">
<van-icon name="bell" size="22" @click="goNotifications" />
</van-badge>
</template>
</van-nav-bar>
<!-- 内容区 -->
<main class="mobile-content">
<router-view />
</main>
<!-- 底部 Tab -->
<van-tabbar v-model="activeTab" @change="onTabChange" placeholder fixed>
<van-tabbar-item name="home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item name="groups" icon="friends-o">群组</van-tabbar-item>
<van-tabbar-item name="games" icon="game-o">游戏</van-tabbar-item>
<van-tabbar-item name="notifications" icon="bell">通知</van-tabbar-item>
<van-tabbar-item name="profile" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<style scoped>
.mobile-app {
min-height: 100vh;
background: var(--gg-bg);
display: flex;
flex-direction: column;
}
.mobile-content {
flex: 1;
padding-bottom: 8px;
}
</style>
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/mobile/MobileLayout.vue
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add MobileLayout with navbar and tabbar"
```
---
## Task 7: 改造路由实现设备分流
**Files:**
- Modify: `frontend/src/router/index.ts`
这是本阶段核心。改造路由:同一 URL,根据 `isMobile()` 动态 import 桌面或手机视图。手机端用 `MobileLayout` 作为布局容器(替代桌面的 `Layout.vue`),子路由指向手机视图(本阶段除 Home 外先用 Placeholder 占位)。
- [ ] **Step 1: 重写 router/index.ts**
`frontend/src/router/index.ts` 完整替换为:
```ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { isAuthenticated } from '@/api/pocketbase'
import { isMobile } from '@/mobile/useDevice'
// 动态选择布局:手机端用 MobileLayout,桌面端用 Layout
const LayoutComponent = () =>
isMobile()
? import('@/mobile/MobileLayout.vue')
: import('@/views/Layout.vue')
// 动态选择视图:同一路由名,根据设备加载桌面/手机视图
function view(desktop: () => Promise<any>, mobile: () => Promise<any>) {
return isMobile() ? mobile : desktop
}
// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: view(
() => import('@/views/Login.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 LoginMobile
),
meta: { requiresGuest: true }
},
{
path: '/register',
name: 'Register',
component: view(
() => import('@/views/Register.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 RegisterMobile
),
meta: { requiresGuest: true }
},
{
path: '/',
component: LayoutComponent,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Home',
component: view(
() => import('@/views/Home.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 HomeMobile
)
},
{
path: 'mobile-groups',
name: 'MobileGroups',
component: view(
() => import('@/views/Home.vue'), // 桌面端无此路由,回退首页
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 GroupsMobile
)
},
{
path: 'mobile-notifications',
name: 'MobileNotifications',
component: view(
() => import('@/views/Home.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 3 替换为 NotificationsMobile
)
},
{
path: 'group/:id',
name: 'GroupView',
component: view(
() => import('@/views/GroupView.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 4 替换为 GroupViewMobile
),
props: true
},
{
path: 'group/:groupId/ledger',
name: 'LedgerView',
component: view(
() => import('@/views/LedgerView.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 LedgerMobile
),
props: true
},
{
path: 'group/:groupId/assets',
name: 'AssetView',
component: view(
() => import('@/views/AssetView.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 AssetMobile
),
props: true
},
{
path: 'group/:groupId/blacklist',
name: 'BlacklistView',
component: view(
() => import('@/views/BlacklistView.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 BlacklistMobile
),
props: true
},
{
path: 'group/:groupId/voice/:sessionId',
name: 'VoiceRoom',
component: view(
() => import('@/views/VoiceRoom.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 5 替换为 VoiceRoomMobile
),
props: true
},
{
path: 'games',
name: 'GamesLibrary',
component: view(
() => import('@/views/GamesLibrary.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 7 替换为 GamesLibraryMobile
)
},
{
path: 'profile',
name: 'Profile',
component: view(
() => import('@/views/Profile.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ProfileMobile
)
},
{
path: 'settings',
name: 'Settings',
component: view(
() => import('@/views/Settings.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 SettingsMobile
)
},
{
path: 'changelog',
name: 'Changelog',
component: view(
() => import('@/views/Changelog.vue'),
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ChangelogMobile
)
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue')
}
]
// 创建路由实例
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// 路由守卫(与原逻辑一致)
router.beforeEach((to, _from, next) => {
const authenticated = isAuthenticated()
if (to.meta.requiresAuth && !authenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
if (to.meta.requiresGuest && authenticated) {
next({ name: 'Home' })
return
}
next()
})
export default router
```
- [ ] **Step 2: 提交**
```cmd
git add frontend/src/router/index.ts
git -c user.name="zcode" -c user.email="zcode@local" commit -m "feat(mobile): add device-based route splitting"
```
---
## Task 8: 确认 viewport meta 标签
**Files:**
- Check/Modify: `frontend/index.html`
手机端必须禁止缩放、适配视口宽度。检查 `index.html``<meta name="viewport">`
- [ ] **Step 1: 查看现有 index.html**
Run:
```cmd
type frontend\index.html
```
- [ ] **Step 2: 确认/修改 viewport meta**
如果 `<head>` 内没有 viewport meta,或不是以下内容,将其改为:
```html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
```
`viewport-fit=cover` 配合 Task 3 CSS 里的 `env(safe-area-inset-bottom)` 处理刘海屏。保留现有 `<title>` 等其他内容不变。
> 注意:如果 index.html 已有正确 viewport,此 Task 跳过修改直接进入 Step 3。
- [ ] **Step 3: 提交(如有修改)**
```cmd
git add frontend/index.html
git -c user.name="zcode" -c user.email="zcode@local" commit -m "fix(mobile): ensure viewport meta for mobile scaling"
```
---
## Task 9: TypeScript 类型检查与构建验证
**Files:**
- 无修改(仅验证)
- [ ] **Step 1: 运行 TypeScript 类型检查**
Run(在 `D:\ZcodeProj\FT\frontend` 下):
```cmd
npx vue-tsc --noEmit
```
Expected: 无类型错误。如果报错,常见原因:
- Vant 组件类型未识别 → 确认 `vant` 已安装且 `main.ts` 引入
- `@/mobile/useDevice` 路径找不到 → 确认 `tsconfig.json` 的 paths 配置包含 `@/*`(现有项目已有)
- 修复后重新运行直到通过
- [ ] **Step 2: 运行生产构建**
Run:
```cmd
npm run build
```
Expected: 构建成功,`dist/` 目录生成。如果失败,根据报错修复(通常是 import 路径或类型问题)。
- [ ] **Step 3: 提交(如有修复)**
如果构建过程中修复了任何问题:
```cmd
git add -A
git -c user.name="zcode" -c user.email="zcode@local" commit -m "fix(mobile): resolve type/build errors"
```
---
## Task 10: 部署到 Dev 环境并验证
**Files:**
- 无修改(部署与验收测试)
- [ ] **Step 1: 部署到 Dev 环境**
Run(在项目根 `D:\ZcodeProj\FT` 下):
```cmd
bash deploy-dev.sh
```
> 如果 Windows 无 bash,参考 `deploy-dev.sh` 内容手动执行等价命令(通常是 `cd frontend && npm run build` 后用 docker-compose 重建 `gamegroup-frontend-dev` 容器)。
Expected: 容器重建成功,监听 7033 端口。
- [ ] **Step 2: 桌面浏览器验证(回归不破坏)**
在桌面浏览器打开 `http://192.168.1.14:7033`
- [ ] 登录页正常显示(桌面版 Login.vue)
- [ ] 登录后进入桌面版首页(左侧边栏 + 右侧主区)
- [ ] 桌面端布局与改动前完全一致
- [ ] 打开浏览器 DevTools Console 无报错
- [ ] **Step 3: 手机端验证(模拟移动设备)**
在桌面浏览器 DevTools 切换到移动设备模拟(如 Chrome 的 Toggle device toolbar,选 iPhone 12):
- [ ] 刷新页面 → 自动进入手机版(顶部 NavBar + 底部 Tabbar
- [ ] 顶部栏显示标题 + 右侧通知铃铛图标
- [ ] 底部 5 个 Tab 显示:首页/群组/游戏/通知/我的
- [ ] 点击底部各 Tab → 路由切换,内容区显示 Placeholder 占位页("该页面手机版开发中")
- [ ] 顶部栏在非 Tab 根页面(如点群组进入详情占位)显示返回箭头
- [ ] 整体绿色主题(按钮、Tab 激活态为 #059669
- [ ] Console 无报错
- [ ] **Step 4: 真机验证(可选但推荐)**
用手机浏览器打开 `http://192.168.1.14:7033`(需与电脑同 WiFi):
- [ ] 自动进入手机版
- [ ] 底部 Tab 单手可达,无横向滚动条
- [ ] 顶部栏和底部 Tab 不被刘海/Home 指示条遮挡
- [ ] **Step 5: 设备切换验证**
在手机版页面的浏览器 Console 执行:
```js
localStorage.setItem('device_mode', 'desktop'); location.reload()
```
- [ ] 刷新后切换到桌面版
- [ ] 执行 `localStorage.removeItem('device_mode'); location.reload()` 恢复自动检测
---
## 验收标准(本阶段完成标志)
- [ ] 桌面端完全不受影响(回归测试通过)
- [ ] 手机端自动识别设备并加载 MobileLayout
- [ ] 底部 5 Tab 导航可切换,路由跳转正常
- [ ] 顶部栏标题、返回箭头、通知铃铛显示正常
- [ ] 绿色主题统一(Vant 变量映射成功)
- [ ] 未实现的页面显示 Placeholder 占位
- [ ] TypeScript 类型检查通过,生产构建成功
- [ ] 已部署到 Dev(7033)并可访问
---
## 后续阶段预告(本计划不实现)
本阶段完成后,后续按以下顺序逐步将各 Placeholder 替换为真实手机视图:
| 阶段 | 内容 | 替换的占位路由 |
|------|------|--------------|
| 2 | 登录/注册 | Login, Register |
| 3 | 首页 + 群组列表 + 通知 | Home, MobileGroups, MobileNotifications |
| 4 | 群组详情核心(动态 + 成员) | GroupView |
| 5 | 组队 + 语音房 UI | VoiceRoom |
| 6 | 投票 + 竞猜 | GroupView 内标签 |
| 7 | 游戏库 | GamesLibrary |
| 8 | 账本 + 资产 + 黑名单 | LedgerView, AssetView, BlacklistView |
| 9 | 回忆 + 统计 + 个人/设置/更新日志 | Profile, Settings, Changelog |
每个阶段一份独立实施计划,每阶段产出可测试的手机页面。