11. 创建布局组件
创建src/layout/index.vue:
<template>
<div class="app-container">
<div class="content-container">
<router-view v-slot="{ Component }">
<keep-alive :include="cacheRoutes">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<div class="tabbar-container safe-area-bottom">
<van-tabbar v-model="active" route>
<van-tabbar-item icon="home-o" to="/home">首页</van-tabbar-item>
<van-tabbar-item icon="apps-o" to="/category">分类</van-tabbar-item>
<van-tabbar-item icon="cart-o" to="/cart">购物车</van-tabbar-item>
<van-tabbar-item icon="user-o" to="/user">我的</van-tabbar-item>
</van-tabbar>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
export default {
name: 'Layout',
setup() {
const route = useRoute()
const active = ref(0)
// 需要缓存的路由
const cacheRoutes = computed(() => {
return ['Home', 'Category', 'User']
})
// 根据当前路由设置底部标签激活状态
const setActive = () => {
const path = route.path
if (path.includes('/home')) {
active.value = 0
} else if (path.includes('/category')) {
active.value = 1
} else if (path.includes('/cart')) {
active.value = 2
} else if (path.includes('/user')) {
active.value = 3
}
}
// 监听路由变化
route.path && setActive()
return {
active,
cacheRoutes
}
}
}
</script>
<style lang="scss" scoped>
.app-container {
height: 100vh;
display: flex;
flex-direction: column;
.content-container {
flex: 1;
overflow-y: auto;
background-color: $background-color-light;
}
.tabbar-container {
width: 100%;
background-color: #fff;
}
}
</style>
12. 创建基础页面
12.1 创建首页 src/views/home/index.vue:
<template>
<div class="home-container">
<van-nav-bar title="首页" fixed />
<div class="content">
<!-- 轮播图 -->
<van-swipe class="banner" :autoplay="3000" indicator-color="white">
<van-swipe-item v-for="(item, index) in banners" :key="index">
<img :src="item.image" />
</van-swipe-item>
</van-swipe>
<!-- 导航菜单 -->
<van-grid :column-num="5" :border="false" class="nav-grid">
<van-grid-item v-for="(item, index) in navs" :key="index" :icon="item.icon" :text="item.text" />
</van-grid>
<!-- 商品列表 -->
<div class="product-list">
<h3 class="section-title">推荐商品</h3>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="product-grid">
<div class="product-item" v-for="(item, index) in productList" :key="index">
<img :src="item.image" class="product-image" />
<div class="product-info">
<div class="product-name">{{ item.name }}</div>
<div class="product-price">¥{{ item.price }}</div>
</div>
</div>
</div>
</van-list>
</div>
</div>
</div>
</template>
import { ref, onMounted } from 'vue'
import { getHomeData } from '@/api/common'
import { Toast } from 'vant'
export default {
name: 'Home',
setup() {
const banners = ref([])
const navs = ref([])
const productList = ref([])
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const limit = 10
// 获取首页数据
const fetchHomeData = async () => {
try {
const res = await getHomeData()
banners.value = res.data.banners || []
navs.value = res.data.navs || []
} catch (error) {
Toast.fail('获取首页数据失败')
console.error(error)
}
}
// 获取商品列表
const fetchProducts = async () => {
try {
const res = await getHomeData({
page: page.value,
limit
})
const newProducts = res.data.products || []
productList.value = [...productList.value, ...newProducts]
// 判断是否还有更多数据
if (newProducts.length < limit) {
finished.value = true
}
page.value++
} catch (error) {
Toast.fail('获取商品列表失败')
console.error(error)
} finally {
loading.value = false
}
}
// 加载更多
const onLoad = () => {
fetchProducts()
}
onMounted(() => {
fetchHomeData()
// 模拟数据
banners.value = [
{ image: 'https://img01.yzcdn.cn/vant/apple-1.jpg' },
{ image: 'https://img01.yzcdn.cn/vant/apple-2.jpg' }
]
navs.value = [
{ icon: 'photo-o', text: '新品' },
{ icon: 'gift-o', text: '礼品' },
{ icon: 'coupon-o', text: '优惠' },
{ icon: 'cart-o', text: '购物车' },
{ icon: 'shop-o', text: '门店' }
]
productList.value = Array(10).fill().map((_, index) => ({
id: index + 1,
name: `商品${index + 1}`,
price: Math.floor(Math.random() * 1000) + 1,
image: `https://img01.yzcdn.cn/vant/ipad.jpeg`
}))
})
return {
banners,
navs,
productList,
loading,
finished,
onLoad
}
}
}
</script>
<style lang="scss" scoped>
.home-container {
padding-top: 46px;
min-height: 100vh;
.content {
padding-bottom: 50px;
}
.banner {
height: 180px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.nav-grid {
margin: 10px 0;
background-color: #fff;
}
.section-title {
padding: 15px;
font-size: $font-size-medium;
color: $text-color-primary;
font-weight: 500;
background-color: #fff;
margin: 10px 0 0;
}
.product-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
padding: 0 10px 10px;
}
.product-item {
background-color: #fff;
border-radius: $border-radius-base;
overflow: hidden;
.product-image {
width: 100%;
height: 150px;
object-fit: cover;
}
.product-info {
padding: 10px;
.product-name {
font-size: $font-size-small;
color: $text-color-primary;
@include ellipsis;
}
.product-price {
font-size: $font-size-medium;
color: $danger-color;
font-weight: 500;
margin-top: 5px;
}
}
}
}
</style>
三段
12.2 创建登录页 src/views/login/index.vue:
<template>
<div class="login-container">
<div class="login-header">
<img src="@/assets/logo.png" class="logo" alt="logo">
<h2 class="title">移动端应用</h2>
</div>
<div class="login-form">
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.username"
name="username"
label="用户名"
placeholder="请输入用户名"
:rules="[{ required: true, message: '请输入用户名' }]"
clearable
/>
<van-field
v-model="form.password"
type="password"
name="password"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '请输入密码' }]"
clearable
/>
</van-cell-group>
<div class="form-actions">
<van-button
round
block
type="primary"
native-type="submit"
:loading="loading"
>
登录
</van-button>
</div>
</van-form>
<div class="other-login">
<div class="divider">
<span>其他登录方式</span>
</div>
<div class="icon-list">
<van-icon name="wechat" size="28" color="#07c160" />
<van-icon name="weibo" size="28" color="#ee0a24" />
<van-icon name="qq" size="28" color="#1989fa" />
</div>
</div>
</div>
<div class="login-footer">
<p>登录即代表您已同意<a href="javascript:;">《用户协议》</a>和<a href="javascript:;">《隐私政策》</a></p>
</div>
</div>
</template>
<script>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { Toast } from 'vant'
export default {
name: 'Login',
setup() {
const router = useRouter()
const store = useStore()
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
const onSubmit = async (values) => {
loading.value = true
try {
await store.dispatch('user/login', values)
Toast.success('登录成功')
router.push('/')
} catch (error) {
Toast.fail('登录失败:' + (error.message || '未知错误'))
} finally {
loading.value = false
}
}
return {
form,
loading,
onSubmit
}
}
}
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 0 20px;
background-color: #fff;
.login-header {
padding-top: 60px;
text-align: center;
margin-bottom: 40px;
.logo {
width: 80px;
height: 80px;
margin-bottom: 15px;
}
.title {
font-size: 24px;
color: $text-color-primary;
font-weight: 500;
}
}
.login-form {
flex: 1;
.form-actions {
margin: 25px 16px 40px;
}
.other-login {
padding: 0 16px;
.divider {
display: flex;
align-items: center;
color: $text-color-secondary;
font-size: $font-size-small;
margin-bottom: 20px;
&::before,
&::after {
content: '';
height: 1px;
flex: 1;
background-color: $border-color-base;
}
span {
padding: 0 12px;
}
}
.icon-list {
@include flex(center, center);
.van-icon {
margin: 0 20px;
}
}
}
}
.login-footer {
padding: 20px 0;
text-align: center;
font-size: $font-size-small;
color: $text-color-secondary;
a {
color: $primary-color;
}
}
}
</style>
12.3 创建用户页 src/views/user/index.vue:
<template>
<div class="user-container">
<!-- 用户信息 -->
<div class="user-header">
<div class="user-info">
<div class="avatar">
<img :src="userInfo.avatar || defaultAvatar" alt="avatar">
</div>
<div class="info">
<h3 class="name">{{ userInfo.nickname || '未登录' }}</h3>
<p class="desc">{{ userInfo.desc || '这个人很懒,什么都没有留下' }}</p>
</div>
</div>
<div class="settings">
<van-icon name="setting-o" size="20" />
</div>
</div>
<!-- 我的订单 -->
<div class="user-card">
<div class="card-header">
<span>我的订单</span>
<span class="more">全部订单 <van-icon name="arrow" /></span>
</div>
<van-grid :column-num="5" :border="false">
<van-grid-item icon="pending-payment" text="待付款" />
<van-grid-item icon="logistics" text="待发货" />
<van-grid-item icon="paid" text="待收货" />
<van-grid-item icon="comment-o" text="待评价" />
<van-grid-item icon="after-sale" text="退款/售后" />
</van-grid>
</div>
<!-- 我的服务 -->
<div class="user-card">
<div class="card-header">
<span>我的服务</span>
</div>
<van-grid :column-num="4" :border="false">
<van-grid-item icon="coupon-o" text="优惠券" />
<van-grid-item icon="fire-o" text="积分商城" />
<van-grid-item icon="medal-o" text="会员中心" />
<van-grid-item icon="location-o" text="收货地址" />
<van-grid-item icon="star-o" text="我的收藏" />
<van-grid-item icon="browsing-history-o" text="浏览记录" />
<van-grid-item icon="question-o" text="帮助中心" />
<van-grid-item icon="service-o" text="联系客服" />
</van-grid>
</div>
<!-- 退出登录 -->
<div class="logout-btn">
<van-button block round plain type="danger" @click="onLogout">退出登录</van-button>
</div>
</div>
</template>
<script>
import { computed, onMounted, ref } from 'vue'
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
import { Dialog, Toast } from 'vant'
import defaultAvatar from '@/assets/default-avatar.png'
export default {
name: 'User',
setup() {
const store = useStore()
const router = useRouter()
// 用户信息
const userInfo = computed(() => store.state.user.userInfo || {})
// 获取用户信息
const fetchUserInfo = async () => {
try {
await store.dispatch('user/getUserInfo')
} catch (error) {
console.error('获取用户信息失败', error)
}
}
// 退出登录
const onLogout = () => {
Dialog.confirm({
title: '提示',
message: '确定要退出登录吗?',
})
.then(async () => {
try {
await store.dispatch('user/logout')
Toast.success('退出成功')
router.push('/login')
} catch (error) {
Toast.fail('退出失败')
}
})
.catch(() => {
// 取消操作
})
}
onMounted(() => {
fetchUserInfo()
})
return {
userInfo,
defaultAvatar,
onLogout
}
}
}
</script>
<style lang="scss" scoped>
.user-container {
min-height: 100vh;
background-color: $background-color-light;
padding-bottom: 50px;
.user-header {
height: 200px;
background-image: linear-gradient(to right, $primary-color, lighten($primary-color, 15%));
padding: 20px;
display: flex;
justify-content: space-between;
color: #fff;
.user-info {
display: flex;
align-items: center;
.avatar {
width: 70px;
height: 70px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
margin-right: 15px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.info {
.name {
font-size: $font-size-large;
font-weight: 500;
margin-bottom: 8px;
}
.desc {
font-size: $font-size-small;
color: rgba(255, 255, 255, 0.8);
}
}
}
.settings {
padding: 10px;
}
}
.user-card {
background-color: #fff;
border-radius: $border-radius-large;
margin: 15px;
padding: 15px 0;
box-shadow: $box-shadow-light;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px 10px;
font-size: $font-size-medium;
font-weight: 500;
.more {
color: $text-color-secondary;
font-size: $font-size-small;
font-weight: normal;
}
}
}
.logout-btn {
margin: 30px 15px;
}
}
</style>
12.4 创建404页面 src/views/error/404.vue:
<template>
<div class="not-found">
<img src="@/assets/404.png" alt="404" class="error-image">
<h2 class="error-title">页面不存在</h2>
<p class="error-desc">抱歉,您访问的页面不存在或已被删除</p>
<van-button round type="primary" @click="goHome">返回首页</van-button>
</div>
</template>
<script>
import { useRouter } from 'vue-router'
export default {
name: 'NotFound',
setup() {
const router = useRouter()
const goHome = () => {
router.push('/')
}
return {
goHome
}
}
}
</script>
<style lang="scss" scoped>
.not-found {
height: 100vh;
@include flex(center, center, column);
padding: 0 30px;
text-align: center;
.error-image {
width: 200px;
margin-bottom: 20px;
}
.error-title {
font-size: 22px;
color: $text-color-primary;
margin-bottom: 10px;
}
.error-desc {
font-size: $font-size-base;
color: $text-color-secondary;
margin-bottom: 30px;
}
}
</style>
13. 权限控制
创建src/permission.js文件,用于控制路由权限:
import router from './router'
import store from './store'
import { getToken } from '@/utils/auth'
// 白名单路由
const whiteList = ['/login', '/register', '/forget']
router.beforeEach(async (to, from, next) => {
// 设置页面标题
document.title = to.meta.title || '移动端应用'
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login') {
// 已登录,跳转到首页
next({ path: '/' })
} else {
// 判断用户信息是否存在
const hasUserInfo = store.getters.userInfo && store.getters.userInfo.id
if (hasUserInfo) {
next()
} else {
try {
// 获取用户信息
await store.dispatch('user/getUserInfo')
next()
} catch (error) {
// 获取用户信息失败,清除token,重定向到登录页
await store.dispatch('user/logout')
next(`/login?redirect=${to.path}`)
}
}
}
} else {
if (whiteList.includes(to.path)) {
// 在白名单中,直接进入
next()
} else {
// 其他没有访问权限的页面将重定向到登录页面
next(`/login?redirect=${to.path}`)
}
}
})
router.afterEach(() => {
window.scrollTo(0, 0)
})
在main.js中引入:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import './permission' // 引入权限控制
import './utils/flexible' // 引入移动端适配
// 引入全局样式
import './styles/index.scss'
// 按需引入Vant组件
import {
Button,
Form,
Field,
CellGroup,
Toast,
Dialog,
NavBar,
Tabbar,
TabbarItem,
Grid,
GridItem,
Icon,
Swipe,
SwipeItem,
List
} from 'vant'
const app = createApp(App)
// 注册Vant组件
const components = [
Button,
Form,
Field,
CellGroup,
Toast,
Dialog,
NavBar,
Tabbar,
TabbarItem,
Grid,
GridItem,
Icon,
Swipe,
SwipeItem,
List
]
components.forEach(component => {
app.use(component)
})
// 全局属性
app.config.globalProperties.$toast = Toast
app.config.globalProperties.$dialog = Dialog
app.use(router)
app.use(store)
app.mount('#app')
14. 添加环境配置
创建不同环境的配置文件:
.env.development:
# 开发环境配置
VITE_APP_TITLE = 移动端应用(开发环境)
VITE_API_BASE_URL = /api
.env.production:
# 生产环境配置
VITE_APP_TITLE = 移动端应用
VITE_API_BASE_URL = https://api.yoursite.com