从0到1搭建Vue3+Vant移动端项目(三)

232 阅读3分钟

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