From 38c13ec50e2d379c65e23ac681131baeada6c80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E9=BA=9F=20=E7=8E=8B?= Date: Thu, 18 Jun 2026 10:54:12 +0800 Subject: [PATCH] feat(mobile): stage 1 - infrastructure (vant, useDevice, MobileLayout, router, mobile.css) - add vant@^4.9.0 dependency - migrate useDevice.ts (device detection + localStorage) - migrate MobileLayout.vue (tabbar + navbar + store init) - migrate mobile.css (vant theme -> --gg-* mapping) - migrate Placeholder.vue for unimplemented mobile views - index.html: viewport for mobile (no-zoom, safe-area) - main.ts: register Vant + import mobile.css - vite.config.ts: manualChunks code splitting (vue/element/vant/pocketbase/livekit) - router/index.ts: device-based routing via isMobile() + view() helper, preserve uat's JoinGroup/JoinTeam routes, mobile views use Placeholder pending stages 2-11 build verified: vue-tsc + vite build pass --- frontend/index.html | 2 +- frontend/package.json | 1 + frontend/src/assets/mobile.css | 53 ++++++++ frontend/src/main.ts | 4 + frontend/src/mobile/MobileLayout.vue | 149 ++++++++++++++++++++++ frontend/src/mobile/useDevice.ts | 51 ++++++++ frontend/src/router/index.ts | 112 +++++++++++++--- frontend/src/views-mobile/Placeholder.vue | 31 +++++ frontend/vite.config.ts | 19 +++ 9 files changed, 406 insertions(+), 16 deletions(-) create mode 100644 frontend/src/assets/mobile.css create mode 100644 frontend/src/mobile/MobileLayout.vue create mode 100644 frontend/src/mobile/useDevice.ts create mode 100644 frontend/src/views-mobile/Placeholder.vue diff --git a/frontend/index.html b/frontend/index.html index ca8701b..700f3a5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Game Group V2 diff --git a/frontend/package.json b/frontend/package.json index 339a1cf..63b3a4d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "livekit-client": "^2.18.3", "pinia": "^2.1.7", "pocketbase": "^0.21.1", + "vant": "^4.9.0", "vue": "^3.4.21", "vue-router": "^4.3.0" }, diff --git a/frontend/src/assets/mobile.css b/frontend/src/assets/mobile.css new file mode 100644 index 0000000..f34789f --- /dev/null +++ b/frontend/src/assets/mobile.css @@ -0,0 +1,53 @@ +/* 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; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 7c7181a..7965628 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,7 +3,10 @@ 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' @@ -19,5 +22,6 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.use(pinia) app.use(router) app.use(ElementPlus) +app.use(Vant) app.mount('#app') diff --git a/frontend/src/mobile/MobileLayout.vue b/frontend/src/mobile/MobileLayout.vue new file mode 100644 index 0000000..066af4f --- /dev/null +++ b/frontend/src/mobile/MobileLayout.vue @@ -0,0 +1,149 @@ + + + + + + + diff --git a/frontend/src/mobile/useDevice.ts b/frontend/src/mobile/useDevice.ts new file mode 100644 index 0000000..1618a0c --- /dev/null +++ b/frontend/src/mobile/useDevice.ts @@ -0,0 +1,51 @@ +// 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) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ad2976d..70436c8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,97 +2,179 @@ 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, mobile: () => Promise) { + return isMobile() ? mobile : desktop +} + +// 阶段 1 占位:尚未实现的 mobile 视图统一指向 Placeholder,后续阶段逐个替换为真实组件 +// 阶段 2: LoginMobile / RegisterMobile +// 阶段 3: HomeMobile / GroupsMobile / NotificationsMobile +// 阶段 4: GroupViewMobile +// 阶段 5: VoiceRoomMobile +// 阶段 7: GamesLibraryMobile +// 阶段 8: LedgerMobile / AssetMobile / BlacklistMobile +// 阶段 9: ProfileMobile / SettingsMobile / ChangelogMobile +// 阶段 11: JoinGroupPageMobile / JoinTeamPageMobile +const mobilePlaceholder = () => import('@/views-mobile/Placeholder.vue') // 路由配置 const routes: RouteRecordRaw[] = [ { path: '/login', name: 'Login', - component: () => import('@/views/Login.vue'), + component: view( + () => import('@/views/Login.vue'), + mobilePlaceholder + ), meta: { requiresGuest: true } }, { path: '/register', name: 'Register', - component: () => import('@/views/Register.vue'), + component: view( + () => import('@/views/Register.vue'), + mobilePlaceholder + ), meta: { requiresGuest: true } }, { path: '/', - component: () => import('@/views/Layout.vue'), + component: LayoutComponent, meta: { requiresAuth: true }, children: [ { path: '', name: 'Home', - component: () => import('@/views/Home.vue') + component: view( + () => import('@/views/Home.vue'), + mobilePlaceholder + ) + }, + { + path: 'mobile-groups', + name: 'MobileGroups', + component: view( + () => import('@/views/Home.vue'), // 桌面端无此路由,回退首页 + mobilePlaceholder + ) + }, + { + path: 'mobile-notifications', + name: 'MobileNotifications', + component: view( + () => import('@/views/Home.vue'), + mobilePlaceholder + ) }, { path: 'group/:id', name: 'GroupView', - component: () => import('@/views/GroupView.vue'), + component: view( + () => import('@/views/GroupView.vue'), + mobilePlaceholder + ), props: true }, { path: 'group/:groupId/ledger', name: 'LedgerView', - component: () => import('@/views/LedgerView.vue'), + component: view( + () => import('@/views/LedgerView.vue'), + mobilePlaceholder + ), props: true, meta: { requiresAuth: true } }, { path: 'group/:groupId/assets', name: 'AssetView', - component: () => import('@/views/AssetView.vue'), + component: view( + () => import('@/views/AssetView.vue'), + mobilePlaceholder + ), props: true, meta: { requiresAuth: true } }, { path: 'group/:groupId/blacklist', name: 'BlacklistView', - component: () => import('@/views/BlacklistView.vue'), + component: view( + () => import('@/views/BlacklistView.vue'), + mobilePlaceholder + ), props: true, meta: { requiresAuth: true } }, { path: 'group/:groupId/voice/:sessionId', name: 'VoiceRoom', - component: () => import('@/views/VoiceRoom.vue'), + component: view( + () => import('@/views/VoiceRoom.vue'), + mobilePlaceholder + ), props: true, meta: { requiresAuth: true } }, { path: 'games', name: 'GamesLibrary', - component: () => import('@/views/GamesLibrary.vue') + component: view( + () => import('@/views/GamesLibrary.vue'), + mobilePlaceholder + ) }, { path: 'profile', name: 'Profile', - component: () => import('@/views/Profile.vue') + component: view( + () => import('@/views/Profile.vue'), + mobilePlaceholder + ) }, { path: 'settings', name: 'Settings', - component: () => import('@/views/Settings.vue') + component: view( + () => import('@/views/Settings.vue'), + mobilePlaceholder + ) }, { path: 'changelog', name: 'Changelog', - component: () => import('@/views/Changelog.vue') + component: view( + () => import('@/views/Changelog.vue'), + mobilePlaceholder + ) } ] }, { path: '/join/group/:groupId', name: 'JoinGroup', - component: () => import('@/views/JoinGroupPage.vue'), + // 邀请落地页:手机端组件阶段 11 填充,现先用 Placeholder 让分流跑通 + component: view( + () => import('@/views/JoinGroupPage.vue'), + mobilePlaceholder + ), props: true }, { path: '/join/team/:sessionId', name: 'JoinTeam', - component: () => import('@/views/JoinTeamPage.vue'), + component: view( + () => import('@/views/JoinTeamPage.vue'), + mobilePlaceholder + ), props: true }, { diff --git a/frontend/src/views-mobile/Placeholder.vue b/frontend/src/views-mobile/Placeholder.vue new file mode 100644 index 0000000..6bbe831 --- /dev/null +++ b/frontend/src/views-mobile/Placeholder.vue @@ -0,0 +1,31 @@ + + + + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4a66df3..475eb4a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -9,6 +9,25 @@ export default defineConfig({ '@': path.resolve(__dirname, 'src') } }, + build: { + rollupOptions: { + output: { + // 代码分割:vendor 按依赖分组,避免单个超大 chunk + manualChunks: { + // Vue 核心运行时(vue + vue-router + pinia) + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + // 桌面端 UI 库 + 'element-plus': ['element-plus', '@element-plus/icons-vue'], + // 手机端 UI 库 + 'vant': ['vant'], + // 后端 SDK + 'pocketbase': ['pocketbase'], + // 语音房依赖(仅 VoiceRoom 用到,体积大,单独拆分) + 'livekit': ['livekit-client'], + } + } + } + }, server: { port: Number(process.env.VITE_PORT) || 5173, proxy: {