feat(mobile): register vant, MobileLayout, device-based routing, viewport meta
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Game Group V2</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>">
|
||||
</head>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<!-- src/mobile/MobileLayout.vue -->
|
||||
<!-- 手机端主布局:顶部栏 + 底部 Tab + 内容区 -->
|
||||
<script setup lang="ts">
|
||||
import { 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 { 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 | number) {
|
||||
const routes: Record<string, string> = {
|
||||
home: '/',
|
||||
groups: '/mobile-groups',
|
||||
games: '/games',
|
||||
notifications: '/mobile-notifications',
|
||||
profile: '/profile'
|
||||
}
|
||||
const target = routes[String(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>
|
||||
@@ -2,84 +2,144 @@
|
||||
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: () => import('@/views/Login.vue'),
|
||||
component: view(
|
||||
() => import('@/views/Login.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 LoginMobile
|
||||
),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
component: view(
|
||||
() => import('@/views/Register.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 2 替换为 RegisterMobile
|
||||
),
|
||||
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'),
|
||||
() => 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: () => import('@/views/GroupView.vue'),
|
||||
component: view(
|
||||
() => import('@/views/GroupView.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 4 替换为 GroupViewMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/ledger',
|
||||
name: 'LedgerView',
|
||||
component: () => import('@/views/LedgerView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
component: view(
|
||||
() => import('@/views/LedgerView.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 LedgerMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/assets',
|
||||
name: 'AssetView',
|
||||
component: () => import('@/views/AssetView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
component: view(
|
||||
() => import('@/views/AssetView.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 AssetMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/blacklist',
|
||||
name: 'BlacklistView',
|
||||
component: () => import('@/views/BlacklistView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
component: view(
|
||||
() => import('@/views/BlacklistView.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 8 替换为 BlacklistMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/voice/:sessionId',
|
||||
name: 'VoiceRoom',
|
||||
component: () => import('@/views/VoiceRoom.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
component: view(
|
||||
() => import('@/views/VoiceRoom.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 5 替换为 VoiceRoomMobile
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
name: 'GamesLibrary',
|
||||
component: () => import('@/views/GamesLibrary.vue')
|
||||
component: view(
|
||||
() => import('@/views/GamesLibrary.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 7 替换为 GamesLibraryMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile.vue')
|
||||
component: view(
|
||||
() => import('@/views/Profile.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ProfileMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue')
|
||||
component: view(
|
||||
() => import('@/views/Settings.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 SettingsMobile
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
name: 'Changelog',
|
||||
component: () => import('@/views/Changelog.vue')
|
||||
component: view(
|
||||
() => import('@/views/Changelog.vue'),
|
||||
() => import('@/views-mobile/Placeholder.vue') // 阶段 9 替换为 ChangelogMobile
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -96,17 +156,15 @@ const router = createRouter({
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user