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 @@
+
+
+
+
+
+
+
+ 当前页面:{{ route.name }}
+
+
+
+
+
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: {