[vue2]智慧商城app

项目演示

查看项目效果, 明确功能模块

项目收获

通过该项目的学习, 可以收获的内容

创建项目

  1. 创建命令: vue create hm-shopping-app
  2. 清理项目多余文件
  3. 清理 路由配置文件 和 App.vue文件
  4. 新增 API接口目录 和 utils工具方法目录

vant组件库

第三方封装好了很多的组件, 整合起来就是一个组件库

官网: Vant 2 - Mobile UI Components built on Vue

使用

  1. 安装: yarn add vant@latest-v2 -S
  2. 如果遇到安装报错, 可能是版本冲突导致, 尝试 npm i vant@latest-v2 -S --legacy-peer-deps 命令
  3. 全部导入与按需导入

  1. 根据官网(快速上手)配置按需导入
  2. 统一管理Vant组件
// 按需导入vant组件
import Vue from 'vue'
import {  Button, Switch } from 'vant'

Vue.use(Button)
Vue.use(Switch)
  1. 引入配置文件
...
import '@/utils/vant-ui'
  1. 验证按需加载是否成功

其他组件库

PC端: element-ui (element-plus)(饿了么) ant-design-vue(阿里)

移动端 vant-ui(有赞) Mint UI(饿了么) Cibe UI(滴滴)

vw适配

开发移动端,就要解决屏幕适配问题, 在脚手架环境中,目前主流的解决方案,就是使用postcss插件, 实现px单位自动换算成vw单位

  1. 安装: yarn add -D postcss-px-to-viewport@1.1.1
  2. 说明: postcss插件不同版本的配置略有差异, 本项目统一使用1.1.1版本
  3. 根目录新建 postcss.config.js文件
module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      // vw适配的标准屏幕宽度 iphoneX
      // 设计图750时, 使用1倍图=>适配375的标准屏幕(最常用)
      // 设计图640时, 使用1倍图=>适配320的标准屏幕
      viewportWidth: 375
    }
  }
}
  1. 测试: css书写px单位, 编译后检查元素单位是否是vw

配置路由

但凡是单个页面, 独立展示的, 都是一级路由

一级路由

  1. 配置路由规则
... ...

const routes = [
  { path: '/', redirect: '/layout' },
  { path: '/login', component: () => import('../views/login/index.vue') },
  { path: '/myorder', component: () => import('../views/myorder/index.vue') },
  { path: '/pay', component: () => import('../views/pay/index.vue') },
  { path: '/search', component: () => import('../views/search/index.vue') },
  { path: '/searchlist', component: () => import('../views/search/list.vue') },
  { path: '/prodetail/:id', component: () => import('../views/prodetail/index.vue') },
  { path: '/search', component: () => import('../views/search/index.vue') },
  { path: '/address', component: () => import('../views/address/index.vue') }
]

.. ...
  1. 一级路由出口
<template>
  <div id="app">
    <router-view></router-view>
  </div>
</template>

二级路由

  1. 配置二级路由规则
... ...

const routes = [
  {
    path: '/layout',
    component: () => import('../views/layout/index.vue'),
    redirect: '/home',
    children: [
      { path: '/home', component: () => import('../views/layout/home.vue') },
      { path: '/cart', component: () => import('../views/layout/cart.vue') },
      { path: '/user', component: () => import('../views/layout/user.vue') },
      { path: '/category', component: () => import('../views/layout/category.vue') }
    ]
  }
]

... ...
  1. 配置导航链接/二级路由出口
<template>
  <div class="continer">

    <router-view></router-view>

    <van-tabbar route active-color="#ee0a24" inactive-color="#000">
      <van-tabbar-item to="/home" icon="wap-home-o">首页</van-tabbar-item>
      <van-tabbar-item to="/category" icon="apps-o">分类</van-tabbar-item>
      <van-tabbar-item to="/cart" icon="shopping-cart-o">购物车</van-tabbar-item>
      <van-tabbar-item to="/user" icon="user-o">我的</van-tabbar-item>
    </van-tabbar>
  </div>
</template>

<script>
export default {
  name: 'LayoutIndex'
}
</script>

<style>
</style>

登录页

1.样式初始化
  1. 新建style/common.less, 重置默认样式
// 重置默认样式
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    font-family: "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
    font-size: 14px;
    // color: #333;
    -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
    -webkit-tap-highlight-color: transparent;
}

// 文字溢出省略号
.text-ellipsis-2 {
    overflow: hidden;
    -webkit-line-clamp: 2;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-box-orient: vertical;
}
  1. main.js导入common.less
... ...
import '@/style/common.less'
... ...
  1. 把图片等素材拷贝到assets目录

2.静态布局
  1. 使用vant-nav-bar组件
<template>
  <div class="login">
    <!-- 注意要按需引入 -->
    <van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)" />
    ... ...
  </div>
</template>
  1. 通过样式覆盖修改箭头的颜色
// 自定义van-nav-bar左侧箭头颜色
.van-nav-bar {
    .van-nav-bar__arrow {
        color: #333;
    }
}
  1. 其他静态结构的编写
<template>
  <div class="login">
    <van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)" />

    <div class="container">
      <div class="title">
        <h3>手机号登录</h3>
        <p>未注册的手机号登录后将自动注册</p>
      </div>

      <div class="form">
        <div class="form-item">
          <input v-model="mobile" class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
        </div>
        <div class="form-item">
          <input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
          <img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
        </div>
        <div class="form-item">
          <input v-model="msgCode" class="inp" placeholder="请输入短信验证码" type="text">
          <button @click="getMagCode">
            {{ second === totalSecond ? '获取验证码' : second + '秒后重新发送'}}
          </button>
        </div>
      </div>

      <div class="login-btn" @click="login">登录</div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'LoginPage',
  data () {
    return { }
  },
  created () { },
  methods: { }
}
</script>

<style lang="less" scoped>
.container {
  padding: 49px 29px;

  .title {
    margin-bottom: 20px;
    h3 {
      font-size: 26px;
      font-weight: normal;
    }
    p {
      line-height: 40px;
      font-size: 14px;
      color: #b8b8b8;
    }
  }

  .form-item {
    border-bottom: 1px solid #f3f1f2;
    padding: 8px;
    margin-bottom: 14px;
    display: flex;
    align-items: center;
    .inp {
      display: block;
      border: none;
      outline: none;
      height: 32px;
      font-size: 14px;
      flex: 1;
    }
    img {
      width: 94px;
      height: 31px;
    }
    button {
      height: 31px;
      border: none;
      font-size: 13px;
      color: #cea26a;
      background-color: transparent;
      padding-right: 9px;
    }
  }

  .login-btn {
    width: 100%;
    height: 42px;
    margin-top: 39px;
    background: linear-gradient(90deg,#ecb53c,#ff9211);
    color: #fff;
    border-radius: 39px;
    box-shadow: 0 10px 20px 0 rgba(0,0,0,.1);
    letter-spacing: 2px;
    display: flex;
    justify-content: center;
    align-items: center;
  }
}
</style>
3.封装axios模块

把axios请求进行封装到request模块, 主要目的是添加一些配置, 如基础地址, 请求和响应拦截器等

如果项目中需要请求不同的地址,可以创建多个axios实例

步骤

  1. 官网: Axios 实例 | Axios中文文档 | Axios中文网
  2. 安装: yarn add axios
  3. 新建src/utils/request.js模块
import axios from 'axios'

// 1,创建axios实例
const instance = axios.create({
  baseURL: 'http://cba.itlike.com/public/index.php?s=/api/',
  timeout: 1500
})

// 2,添加请求拦截器
instance.interceptors.request.use(function (config) {
  // 在发送请求之前做些什么
  return config
}, function (error) {
  // 对请求错误做些什么
  return Promise.reject(error)
})

// 3,添加响应拦截器
instance.interceptors.response.use(function (response) {
  // 2xx 范围内的状态码都会触发该函数。
  // 对响应数据做点什么(默认axios会多包装一层data,需要响应拦截器中处理一下)
  const res = response.data
  
}, function (error) {
  // 超出 2xx 范围的状态码都会触发该函数。
  // 对响应错误做点什么
  return Promise.reject(error)
})

export default instance
  1. 测试使用
... ...
<script>
import request from '@/utils/request'
export default {
  created () {
    request.get('/captcha/image')
  }
}
</script>
4.封装api模块

把请求封装成方法, 与页面分离, 统一管理请求

好处

  1. 请求与页面逻辑分离
  2. 相同的请求可以复用
  3. 请求统一管理便于查找修改

步骤

  1. 新建请求模块 src/api/login.js
  2. 封装请求函数( 方法要按需导出, 请求结果要return )
// 登录相关接口
import request from '@/utils/request'

// 获取图形验证码
export const getPriceCode = () => {
  return request.get('/captcha/image')
}
  1. 页面中导入使用
<script>
import { getPriceCode } from '@/api/login'

export default {
  ... ...
  created () {
    this.getPicCode()
  },
  methods: {
    // 获取图形验证码
    async getPicCode () {
      const { data: { base64, key } } = await getPriceCode()
    },
  }
}
</script>

5.图形验证码

图形验证码本质就是一个请求回来的图片, 作用就是强制人机交互, 可以抵御机器自动化攻击, 比如通过批量请求爆破接口, 恶意刷票,论坛灌水等

需求:

  1. 效果

  1. 把请求回来的base64图片,动态展示出来
  2. 点击验证码盒子, 要刷新验证码

步骤:

  1. 图片就是让用户看的
  2. 用户输入的验证码要和key值一起提交, 服务器才能判断验证是否成功

  1. 代码实现
<template>
  <div class="login">
   ... ...
    <div class="form-item">
      <!-- 3,展示验证码 -->
      <input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
      <img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
    </div>
   ... ...
  </div>
</template>

<script>
import { getPriceCode, getMsgCode, codeLogin } from '@/api/login'

export default {
  name: 'LoginPage',
  data () {
    return {
      picKey: '', // 将来请求传递的图形验证码唯一标识
      picUrl: '', // 存储请求渲染的图片地址
      picCode: '', // 用户输入的图形验证码
    }
  },
  created () {
    this.getPicCode()
  },
  methods: {
    // 1,获取图形验证码
    async getPicCode () {
      const { data: { base64, key } } = await getPriceCode()
      //2, 保存关键数据
      this.picUrl = base64 // 存储图片地址
      this.picKey = key // 存储唯一标识
    },
  }
}
</script>
6.totas轻提示
  1. 注册安装

  1. 导入调用: 组件和非组件都可以使用

  1. this调用: 必须组件内调用

  1. 在注册该组件后, vant自动把该方法, 挂载到了vue原型上 (Vue.prototype.$toast = xxxx)
7,短信验证码

  1. 点击按钮, 实现倒计时效果
  2. 倒计时之前, 校验手机号和验证码
  3. 封装接口, 获取短信验证码
<template>
  <div class="login">
    <div class="container">
      <div class="form">
        <div class="form-item">
          <input v-model="mobile" class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
        </div>
        <div class="form-item">
          <input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
          <img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
        </div>
        <div class="form-item">
          <input v-model="msgCode" class="inp" placeholder="请输入短信验证码" type="text">
          <button @click="getMagCode">
            {{ second === totalSecond ? '获取验证码' : second + '秒后重新发送'}}
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { getMsgCode } from '@/api/login'

export default {
  name: 'LoginPage',
  data () {
    return {
     ... ...
      totalSecond: 60, // 总秒数 (用于归位)
      second: 60, // 当前秒数,(用于倒计时)
      timer: null, // 定时器 id
      mobile: '', // 手机号
      picCode: '', // 用户输入的图形验证码
      msgCode: '246810' // 短信验证码
    }
  },
  methods: {
    // 手机号/图形码校验
    validFn () {
      if (!/^1[3-9]\d{9}$/.test(this.mobile)) {
        this.$toast('请输入正确的手机号码')
        return false
      }

      if (!/^\w{4}$/.test(this.picCode)) {
        this.$toast('请输入正确的图形验证码')
        return false
      }

      return true
    },

    //  获取短信验证码
    async getMagCode () {
      // 2,校验手机号/图形码
      if (!this.validFn()) return

      // 定时器为null 且 当前秒数==总秒数, 才可以获取短信验证码
      if (!this.timer && this.second === this.totalSecond) {
        // 3,获取短信验证码
        await getMsgCode(this.picCode, this.picKey, this.mobile)
        this.$toast('验证码已发送')

        // 1,倒计时效果
        this.timer = setInterval(() => {
          this.second--

          if (this.second <= 0) {
            clearInterval(this.timer) // 清除定时器
            this.timer = null // 清除定时器
            this.second = this.totalSecond // 重置秒数
          }
        }, 1000)
      }
    },

    destroyed () {
    // 清除定时器
      clearInterval(this.timer)
    }
  }
}
</script>

8.登录

  1. 封装登录接口
  2. 登录前校验(手机号/图形验证码/短信验证码)
  3. 发起登录请求, 成功后提示, 跳转首页
<template>
  <div class="login">
      ... ...
      <div class="login-btn" @click="login">登录</div>
    </div>
  </div>
</template>

<script>
import { getPriceCode, getMsgCode, codeLogin } from '@/api/login'

export default {
  name: 'LoginPage',
  data () {
    return {
      mobile: '', // 手机号
      msgCode: '246810' // 短信验证码
    }
  },
  methods: {
    ... ...
    // 登录
    async login () {
      // 校验手机号/图形码
      if (!this.validFn()) return

      // 校验短信验证码
      if (!/^\d{6}$/.test(this.msgCode)) {
        this.$toast('请输入正确的短信验证码')
        return
      }

      // 登录
      const res = await codeLogin(this.mobile, this.msgCode)
      this.$toast('登录成功')
      this.$router.replace('/')
    },
  }
}
</script>
9.统一处理错误

所有的请求, 都可能出现错误,, 可以单独处理, 但是更好的建议是, 通过响应拦截器, 统一处理接口错误

优势:

  1. 统一处理请求错误后, 请求相关的代码都只需要考虑正常逻辑即可
  2. 全局处理请求错误依赖后台, 后台需要返回合适的错误信息

import { Toast } from 'vant'
... ...
// 响应拦截器
instance.interceptors.response.use(function (response) {
  // 2xx 范围内的状态码都会触发该函数。
  const res = response.data
  
  // 统一处理请求错误
  if (res.status !== 200) {
    // 预期: 响应的状态码非200, 抛出错误, 给用户提示
    // 原理: await只会等待成功的promise, 抛出错误后程序不再往下执行
    Toast(res.message) // 弹出提示
    return Promise.reject(res) // 返回一个错误的promise
  }

  return res
}, function (error) {
  // 超出 2xx 范围的状态码都会触发该函数。
  // 对响应错误做点什么
  return Promise.reject(error)
})

10.保存权证信息

把权证信息保存到vuex中, 方便其他页面的使用

  1. 构建user模块
export default {
  namespaced: true,
  state () {
    return {}
  },
  mutations: {},
  actions: {},
  getters: {}
}
  1. 挂载到vuex
import user from './modules/user'

export default new Vuex.Store({
  ... ...
  modules: {
    user,
  }
})
  1. 提供mutations
export default {
  namespaced: true,
  state () {
    return {
      // 个人权证相关
      userInfo: {
        token: '',
        userId: '',
      }
    }
  },
  mutations: {
    // 设置用户信息
    SET_USER_INFO (state, userInfo) {
      state.userInfo = userInfo
      setInfo(userInfo) // 存储用户信息到本地
    }
  },
  actions: {},
  getters: {}
}
  1. 页面中调用
<script>
export default {
  ... ...
  methods: {
    ... ...
    // 登录
    async login () {
      ... ...
      const res = await codeLogin(this.mobile, this.msgCode)
      // 保存用户权证
      this.$store.commit('user/SET_USER_INFO', res.data)
      ... ...
    },
  }
}
</script>
11.持久化存储

封装storage存储模块, 利用本地存储, 进行vuex持久化处理

// 约定通用的键名
const INFO_KEY = 'hm_shopping_info'

// 获取个人信息
export const getInfo = () => {
  const defaultObj = { token: '', userId: '' }
  const result = localStorage.getItem(INFO_KEY)
  return result ? JSON.parse(result) : defaultObj
}

// 设置个人信息
export const setInfo = (obj) => {
  localStorage.setItem(INFO_KEY, JSON.stringify(obj))
}

// 清除个人信息
export const clearInfo = () => {
  localStorage.removeItem(INFO_KEY)
}
import { getInfo, setInfo } from '@/utils/storage'

export default {
  namespaced: true,
  state () {
    return {
      // 个人权证相关
      userInfo: getInfo()
    }
  },
  mutations: {
    // 设置用户信息
    SET_USER_INFO (state, userInfo) {
      state.userInfo = userInfo
      setInfo(userInfo) // 存储用户信息到本地
    }
  },
  actions: {},
  getters: {}
}
12.添加loading效果

请求的时候统一添加loading效果

好处

  1. 节流处理: 防止用户在一次请求结束之前, 多次进行点击, 发送无效的请求
  2. 友好提示: 告知用户, 正在加载中, 用户体验更好

步骤

  1. 在请求拦截器中, 打开loading
  2. 在响应拦截器中, 关闭loading

import { Toast } from 'vant'

// 请求拦截器
instance.interceptors.request.use(function (config) {
  // 在发送请求之前做些什么
  // 1,开启loading效果
  Toast.loading({
    message: '加载中...',
    forbidClick: true,
    loadingType: 'spinner', // loading的类型
    duration: 0 // 不会自动消失
  })
 ... ...
}, function (error) {
  ... ...
})

// 响应拦截器
instance.interceptors.response.use(function (response) {
  if (res.status !== 200) {
    ... ...
  } else {
    // 请求正常, 关闭loading效果,代码继续执行后面的业务
    Toast.clear()
  }
}, function (error) {
    ... ...
})

13.页面鉴权

基于全局前置路由守卫, 进行页面访问拦截处理

路由导航守卫

  1. 所有的路由一旦被匹配到, 都会先经过全局前置守卫
  2. 只有全局前置守卫放行, 才会真正解析渲染组件, 才能看到页面的内容
  3. fullPath可以拿到路由参数, path只可以拿到路由

import store from '@/store/index'
... ...
// 所有的权限页面
const authUrls = ['/pay','/myorder'] 
// 路由前置守卫
router.beforeEach((to, from, next) => {
  if (!authUrls.includes(to.path)) {
    // 非权限页面 直接放行
    next()
    // return是必须的, 不然会一直执行next()
    return
  }

  const token = store.getters.token
  if (token) {
    // 权限页面, 判断有token, 放行
    next()
  } else {
    // 权限页面, 无token,强制去登录
    next('/login')
  }
})
... ...
export default new Vuex.Store({
  getters: {
    // 方便获取token, 封装getters
    token (state) {
      return state.user.userInfo.token
    }
  },
 ... ...
})

首页

1.0静态结构

2.0封装接口
import request from '@/utils/request'

// 获取首页数据
export const getHomeData = () => {
  return request.get('/page/detail', {
    params: {
      pageId: 0
    }
  })
}
3.0页面调用
<script>
import { getHomeData } from '@/api/home'
export default {
  data () {
    return {
      bannerList: [], // 轮播
      navList: [], // 导航
      proList: [] // 商品
    }
  },
  async created () {
    const { data: { pageData } } = await getHomeData()
    this.bannerList = pageData.items[1].data
    this.navList = pageData.items[3].data
    this.proList = pageData.items[6].data
  }
}
</script>
4.0动态渲染
<template>
  <div class="home">
    <!-- 导航条 -->
    <van-nav-bar title="智慧商城" fixed />

    <!-- 搜索框 -->
    <van-search
      readonly
      shape="round"
      background="#f1f1f2"
      placeholder="请在此输入搜索关键词"
      @click="$router.push('/search')"
    />

    <!-- 轮播图 -->
    <van-swipe class="my-swipe" :autoplay="3000" indicator-color="white">
      <van-swipe-item v-for="item in bannerList" :key="item.imgUrl">
        <img :src="item.imgUrl" alt="">
      </van-swipe-item>
    </van-swipe>

    <!-- 导航 -->
    <van-grid column-num="5" icon-size="40">
      <van-grid-item
        v-for="item in navList" :key="item.imgUrl"
        :icon="item.imgUrl"
        text="新品首发"
        @click="$router.push('/category')"
      />
    </van-grid>

    <!-- 主会场 -->
    <div class="main">
      <img src="@/assets/main.png" alt="">
    </div>

    <!-- 猜你喜欢 -->
    <div class="guess">
      <p class="guess-title">—— 猜你喜欢 ——</p>

      <div class="goods-list">
        <GoodsItem v-for="item in proList" :key="item.goods_id" :item="item"></GoodsItem>
      </div>
    </div>
  </div>
</template>
<template>
    <div v-if="item.goods_id" class="goods-item" @click="$router.push(`/prodetail/${item.goods_id}`)">
      <div class="left">
        <img :src="item.goods_image" alt="" />
      </div>
      <div class="right">
        <p class="tit text-ellipsis-2">
          {{ item.goods_name }}
        </p>
        <p class="count">已售 {{ item.goods_sales }} 件</p>
        <p class="price">
          <span class="new">¥{{ item.goods_price_min }}</span>
          <span class="old">¥{{ item.goods_price_max }}</span>
        </p>
      </div>
    </div>
  </template>

<script>
export default {
  name: 'GoodsItem',
  props: {
    item: {
      type: Object,
      default: () => {
        return {}
      }
    }
  }
}
</script>
5.0搜索管理页

需求

  1. 搜索历史动态渲染
  2. 点击搜索按钮或者历史记录, 进行搜索
  3. 已存在的搜索关键字, 先移除再添加到最前面
  4. 不存在的搜索关键字, 添加到最前面
  5. 点击清空图标, 清空历史记录
  6. 持久化储存

代码

<template>
  <div class="search">
    <van-nav-bar title="商品搜索" left-arrow @click-left="$router.go(-1)" />

    <van-search v-model="search" show-action placeholder="请输入搜索关键词" clearable>
      <template #action>
        <div @click="goSearch(search)">搜索</div>
      </template>
    </van-search>

    <!-- 搜索历史 -->
    <div class="search-history" v-if="history.length > 0">
      <div class="title">
        <span>最近搜索</span>
        <van-icon @click="clear" name="delete-o" size="16" />
      </div>
      <div class="list">
        <div v-for="item in history" :key="item" class="list-item" @click="goSearch(item)">
          {{ item }}
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { setHistory, getHistory } from '@/utils/storage'
export default {
  name: 'SearchIndex',
  data () {
    return {
      search: '', // 输入框的内容
      history: getHistory() // 历史记录
    }
  },
  methods: {
    // 添加搜索历史
    goSearch (key) {
      const index = this.history.indexOf(key)
      if (index !== -1) {
        // 存在于原数组, 删除
        this.history.splice(index, 1)
      }
      // 头部追加
      this.history.unshift(key)
      // 持久化存储
      setHistory(this.history)
      // 跳转列表页
      this.$router.push(`/searchlist?search=${key}`)
    },

    // 清空搜索历史
    clear () {
      this.history = []
      setHistory([])
    }
  }
}
</script>
// 约定通用的键名
const HISTORY_KEY = 'hm_history_key'

// 添加搜索历史记录
export const setHistory = (arr) => {
  localStorage.setItem(HISTORY_KEY, JSON.stringify(arr))
}

// 获取搜索历史记录
export const getHistory = () => {
  const result = localStorage.getItem(HISTORY_KEY)
  return result ? JSON.parse(result) : []
}
6.0搜索列表页

需求

  1. 准备静态结构
  2. 封装接口
  3. 请求数据
  4. 动态渲染

代码

import request from '@/utils/request'

// 获取搜索商品列表
export const getProList = (obj) => {
  const { categoryId, goodsName, page } = obj
  return request.get('/goods/list', {
    params: {
      categoryId, // 商品分类id
      goodsName,  // 商品名称
      page        // 当前页
    }
  })
}
<template>
  <div class="search">
    <van-nav-bar fixed title="商品列表" left-arrow @click-left="$router.go(-1)" />

    <van-search
      readonly
      shape="round"
      background="#ffffff"
      :value="querySearch || '搜索商品'"
      show-action
      @click="$router.push('/search')"
    >
      <template #action>
        <van-icon class="tool" name="apps-o" />
      </template>
    </van-search>

    <!-- 排序选项按钮 -->
    <div class="sort-btns">
      <div class="sort-item">综合</div>
      <div class="sort-item">销量</div>
      <div class="sort-item">价格 </div>
    </div>
      
     <!-- 3,渲染商品列表 -->
    <div class="goods-list">
      <GoodsItem v-for="item in proList" :key="item.goods_id" :item="item"></GoodsItem>
    </div>
  </div>
</template>

<script>
import GoodsItem from '@/components/GoodsItem.vue'
import { getProList } from '@/api/product'
export default {
  name: 'SearchIndex',
  components: {
    GoodsItem
  },
  computed: {
    // 1, 获取地址栏的搜索关键字
    querySearch () {
      return this.$route.query.search
    }
  },
  data () {
    return {
      page: 1,
      proList: []
    }
  },
  // 2, 请求列表数据
  async created () {
    const { data: { list } } = await getProList({
      goodsName: this.querySearch,
      page: this.page
      // 商品分类id: 兼容商品分类页, 
      // 如果值是undefiend或null, axios会自动过滤
      categoryId: this.$route.query.categoryId,
    })
    this.proList = list.data
  }
}
</script>
7.0商品分类页

需求

  1. 准备静态结构
  2. 封装接口
  3. 请求数据
  4. 动态渲染
  5. 点击跳转搜索列表页

代码

import request from '@/utils/request'

// 获取分类数据
export const getCategoryData = () => {
  return request.get('/category/list')
}
<template>
  <div class="category">
    <!-- 分类 -->
    <van-nav-bar title="全部分类" fixed />

    <!-- 搜索框 -->
    <van-search
      readonly
      shape="round"
      background="#f1f1f2"
      placeholder="请输入搜索关键词"
      @click="$router.push('/search')"
    />

    <!-- 分类列表 -->
    <!-- 2,渲染数据 -->
    <div class="list-box">
      <div class="left">
        <ul>
          <li v-for="(item, index) in list" :key="item.category_id">
            <a :class="{ active: index === activeIndex }" @click="activeIndex = index" href="javascript:;">{{ item.name }}</a>
          </li>
        </ul>
      </div>
      <div class="right">
        <!-- 3,点击二级分类, 跳转搜索列表页  -->
        <div @click="$router.push(`/searchlist?categoryId=${item.category_id}`)" v-for="item in list[activeIndex]?.children" :key="item.category_id" class="cate-goods">
          <img :src="item.image?.external_url" alt="">
          <p>{{ item.name }}</p>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { getCategoryData } from '@/api/category'
export default {
  name: 'CategoryPage',
  created () {
    // 1, 请求数据
    this.getCategoryList()
  },
  data () {
    return {
      list: [],
      activeIndex: 0
    }
  },
  methods: {
    async getCategoryList () {
      const { data: { list } } = await getCategoryData()
      this.list = list
    }
  }
}
</script>

商品详情页

步骤

  1. 静态结构
  2. 封装接口
  3. 请求数据
  4. 渲染页面

代码

import request from '@/utils/request'

// 获取商品详情数据
export const getProDetail = (goodsId) => {
  return request.get('/goods/detail', {
    params: {
      goodsId
    }
  })
}

// 获取商品评价
export const getProComments = (goodsId, limit) => {
  return request.get('/comment/listRows', {
    params: {
      goodsId,
      limit
    }
  })
}
<template>
  <div class="prodetail">
    <van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />

    <van-swipe :autoplay="4000" @change="onChange">
      <van-swipe-item v-for="(image, index) in images" :key="index">
        <img :src="image.external_url" />
      </van-swipe-item>

      <template #indicator>
        <div class="custom-indicator">{{ current + 1 }} / {{ images.length }}</div>
      </template>
    </van-swipe>

    <!-- 3,商品说明 -->
    <div class="info">
      <div class="title">
        <div class="price">
          <span class="now">¥{{ detail.goods_price_min }}</span>
          <span class="oldprice">¥{{ detail.goods_price_max }}</span>
        </div>
        <div class="sellcount">已售 {{ detail.goods_sales }} 件</div>
      </div>
      <div class="msg text-ellipsis-2">
        {{ detail.goods_name }}
      </div>

      <div class="service">
        <div class="left-words">
          <span><van-icon name="passed" />七天无理由退货</span>
          <span><van-icon name="passed" />48小时发货</span>
        </div>
        <div class="right-icon">
          <van-icon name="arrow" />
        </div>
      </div>
    </div>

    <!-- 5,商品评价 -->
    <div class="comment">
      <div class="comment-title">
        <div class="left">商品评价 ({{ total }}条)</div>
        <div class="right">查看更多 <van-icon name="arrow" /> </div>
      </div>
      <div class="comment-list">
        <div class="comment-item" v-for="item in commentList" :key="item.comment_id">
          <div class="top">
            <!-- 头像可能为null, 所以设置一个默认值 --> 
            <img :src="item.user.avatar_url || defaultImg" alt="">
            <div class="name">{{ item.user.nick_name }}</div>
            <van-rate :size="16" :value="item.score / 2" color="#ffd21e" void-icon="star" void-color="#eee"/>
          </div>
          <div class="content">
            {{ item.content }}
          </div>
          <div class="time">
            {{ item.create_time }}
          </div>
        </div>
      </div>
    </div>

    <!-- 4,商品描述 -->
    <div class="desc" v-html="detail.content">
    </div>
  </div>
</template>

<script>
import { getProComments, getProDetail } from '@/api/product'
import defaultImg from '@/assets/default-avatar.png'

export default {
  name: 'ProDetail',
  data () {
    return {
      images: [],
      current: 0,
      detail: {},
      total: 0, // 评价总数
      commentList: [], // 评价列表
      defaultImg, // 评论的默认头像(头像可能会为null)
    }
  },
  computed: {
    goodsId () {
      return this.$route.params.id
    }
  },
  created () {
    this.getDetail()
    this.getComments()
  },
  methods: {
    onChange (index) {
      this.current = index
    },
    // 1,获取商品详情
    async getDetail () {
      const { data: { detail } } = await getProDetail(this.goodsId)
      this.detail = detail
      this.images = detail.goods_images
    },
    // 2,获取商品评价
    async getComments () {
      const { data: { list, total } } = await getProComments(this.goodsId, 3)
      this.commentList = list
      this.total = total
    },
  }
}
</script>

加入购物车

1.0唤起弹层

需求

  1. 熟悉van-action-sheet组件
  2. 完善弹层结构
  3. 动态渲染

<template>
  <div class="prodetail">
    ... ...
    <!-- 底部 -->
    <div class="footer">
      <div @click="$router.push('/')" class="icon-home">
        <van-icon name="wap-home-o" />
        <span>首页</span>
      </div>
      <div @click="$router.push('/cart')" class="icon-cart">
        <span v-if="cartTotal > 0" class="num">{{ cartTotal }}</span>
        <van-icon name="shopping-cart-o" />
        <span>购物车</span>
      </div>
      <div @click="addFn" class="btn-add">加入购物车</div>
      <div @click="buyNow" class="btn-buy">立刻购买</div>
    </div>

    <!-- 加入购物车/立即购买 公用的弹层 -->
    <van-action-sheet v-model="showPannel" :title="mode === 'cart' ? '加入购物车' : '立刻购买'">
      <div class="product">
        <div class="product-title">
          <div class="left">
            <img :src="detail.goods_image" alt="">
          </div>
          <div class="right">
            <div class="price">
              <span>¥</span>
              <span class="nowprice">{{ detail.goods_price_min }}</span>
            </div>
            <div class="count">
              <span>库存</span>
              <span>{{ detail.stock_total }}</span>
            </div>
          </div>
        </div>
        <div class="num-box">
          <span>数量</span>
          <span>数字框组件占位</span?
        </div>

        <!-- 有库存才显示提交按钮 -->
        <div class="showbtn" v-if="detail.stock_total > 0">
          <div class="btn" v-if="mode === 'cart'">加入购物车</div>
          <div class="btn now" v-else >立刻购买</div>
        </div>
        <!-- 没有库存显示商品暂无 -->
        <div class="btn-none" v-else>该商品已抢完</div>
      </div>
    </van-action-sheet>
  </div>
</template>
2.0数字框组件

步骤

  1. 静态结构, 左中右三部分
  2. 数字框的数字, 应该是外部传递
  3. 点击+-号, 可以修改数据
  4. 使用v-model实现封装
  5. 数字不能小于1

代码

<template>
 <!-- 加入购物车/立即购买 公用的弹层 -->
    <van-action-sheet>
     ... ...
      <div class="num-box">
          <span>数量</span>
          <!-- 1, 使用组件 -->
          <!-- v-model 本质上 :value 和 @input 的简写 -->
          <CountBox v-model="addCount"></CountBox>
        </div>
    </van-action-sheet>
</template>

<script>
import CountBox from '@/components/CountBox.vue'
export default {
  components: {
    CountBox
  },
  data () {
    return {
      addCount: 1, // 数字框绑定的数据
    }
  },
}
</script>
<template>
  <div class="count-box">
    <button @click="handleSub" class="minus">-</button>
    <input :value="value" @change="handleChange" class="inp" type="text">
    <button @click="handleAdd" class="add">+</button>
  </div>
</template>

<script>
export default {
  props: {
    value: {
      type: Number,
      default: 1
    }
  },
  methods: {
    // +号事件
    handleSub () {
      if (this.value <= 1) {
        return
      }
      this.$emit('input', this.value - 1)
    },
    // -号事件
    handleAdd () {
      this.$emit('input', this.value + 1)
    },
    // 输入事件
    handleChange (e) {
      const num = +e.target.value // 转数字处理 (1) 数字 (2) NaN

      // 输入了不合法的文本 或 输入了负值,回退成原来的 value 值
      if (isNaN(num) || num < 1) {
        e.target.value = this.value
        return
      }

      this.$emit('input', num)
    }
  }
}
</script>
3.0添加购物车

步骤

  1. 加入购物车, 是一个登录后的用户才能进行的操作
  2. 不存在token, 引导用户登录, 登录完回跳
  3. 存在token, 继续加入购物车
  4. 封装添加购物车的接口(添加请求头参数)
  5. 加入购物车后, 展示数量的角标

代码

// 添加请求拦截器
instance.interceptors.request.use(function (config) {

  // 只要有token, 就在请求时携带token, 便于请求需要授权的接口
  const token = store.getters.token
  if (token) {
    config.headers['Access-Token'] = token
    config.headers.platform = 'H5'
  }

  return config
}, function (error) {
  ... ...
})
import request from '@/utils/request'

// 加入购物车
// goodsId    => 商品id     iphone8
// goodsSkuId => 商品规格id  红色的iphone8  粉色的iphone8
export const addCart = (goodsId, goodsNum, goodsSkuId) => {
  return request.post('/cart/add', {
    goodsId,
    goodsNum,
    goodsSkuId
  })
}
<template>
  <div class="prodetail">
    ... ...
    <!-- 底部 -->
    <div class="footer">
     ... ...
      <div @click="$router.push('/cart')" class="icon-cart">
        <!-- 6,展示购物车角标 -->
        <span v-if="cartTotal > 0" class="num">{{ cartTotal }}</span>
        <van-icon name="shopping-cart-o" />
        <span>购物车</span>
      </div>
    </div>

    <!-- 加入购物车/立即购买 公用的弹层 -->
    <van-action-sheet>
        ... ...
        <div class="showbtn" v-if="detail.stock_total > 0">
          <!-- 1, 加入购物车 -->
          <div class="btn" v-if="mode === 'cart'" @click="addCart">加入购物车</div>
          <div class="btn now" v-else @click="goBuyNow">立刻购买</div>
        </div>
    </van-action-sheet>
  </div>
</template>

<script>
import { addCart } from '@/api/cart'

export default {
  name: 'ProDetail',
  data () {
    return {
      showPannel: false, // 控制弹层的显示隐藏
      mode: 'cart', // 标记弹层状态
      addCount: 1, // 数字框绑定的数据
      cartTotal: 0 // 购物车角标
    }
  },
  computed: {
    goodsId () {
      return this.$route.params.id
    }
  },
  methods: {
    // 登录判断
    loginConfirm () {
      if (!this.$store.getters.token) {
        // 4,没有登录, 展示弹窗
        this.$dialog.confirm({
          title: '温馨提示',
          message: '登录后才可以操作购物车',
          confirmButtonText: '去登录',
          cancelButtonText: '再逛逛'
        })
          .then(() => {
            this.$router.replace({
              path: '/login',
              query: {
                 // 携带参数,用于回跳
                backPath: this.$route.fullPath
              }
            })
          })
        
        return false
      }

      // 已经登录
      return true
    }
    
    // 2,加入购物车
    async addCart () {
      // 3,判断是否登录
      if (!this.loginConfirm()) {
        return
      }
      // 5,已经登录
      const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
      this.cartTotal = data.cartTotal // 保存角标
      this.$toast('加入购物车成功')
      this.showPannel = false
    },
  }
}
</script>
<script>
export default {
  methods: {
    async login () {
      ... ...
      this.$toast('登录成功')
      // 判断处理: 
      // 1,地址栏有参数,别的页面拦截过来的->回跳操作
      // 2,地址栏无参数->去首页
      const url = this.$route.query.backPath || '/'
      this.$router.replace(url)
    },
  }
}
</script>

购物车

需求

  1. 静态结构
  2. 创建vuex的cart模块
  3. 请求数据, 动态渲染购物车列表
  4. 封装getters实现动态统计
  5. 全选反选功能
  6. 数字框修改数量功能
  7. 编辑切换状态, 删除功能
  8. 空购物车处理

1.0创建cart模块
import request from '@/utils/request'

// 获取购物车列表
export const getCartList = () => {
  return request.get('/cart/list')
}
import { getCartList } from '@/api/cart'

export default {
  namespaced: true,
  state () {
    return {
      cartList: []
    }
  },
  mutations: {
    // 转存购物车数据
    setCartList (state, cartList) {
      state.cartList = cartList
    },
  },
  actions: {
    // 获取购物车列表
    async GET_CART_LIST ({ commit }) {
      const { data } = await getCartList()

      // 后台返回的数据中缺少复选框状态, 我们自己添加
      data.list.forEach(item => {
        item.isChecked = true
      })

      commit('setCartList', data.list)
    },
  },
  getters: {}
}
import cart from './modules/cart'

export default new Vuex.Store({
  modules: {
    cart
  }
})
<script>
export default {
  computed: {
    // 用户是否登录
    isLogin () {
      return this.$store.getters.token
    }
  },
  created () {
    // 必须是登录过的用户,才能用户购物车列表
    if (this.isLogin) {
      this.$store.dispatch('cart/GET_CART_LIST')
    }
  },
}
</script>
2.0渲染购物车列表
<template>
  <div class="cart">
    ... ...
    <div v-if="isLogin && cartList.length > 0">
      <!-- 购物车列表 -->
      <div class="cart-list">
        <div class="cart-item" v-for="item in cartList" :key="item.goods_id">
          <van-checkbox :value="item.isChecked"></van-checkbox>
          <div class="show">
            <img :src="item.goods.goods_image" alt="">
          </div>
          <div class="info">
            <span class="tit text-ellipsis-2">{{ item.goods.goods_name }}</span>
            <span class="bottom">
              <div class="price">¥ <span>{{ item.goods.goods_price_min }}</span></div>
              <!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层 -->
              <!-- @input="changeCount" -->
              <CountBox :value="item.goods_num"></CountBox>
            </span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'
export default {
  computed: {
    ...mapState('cart', ['cartList']),
  },
}
</script>
3.0动态统计
export default {
  namespaced: true,
  state () {
    return {
      cartList: []
    }
  },
  getters: {
    // 所有商品的总数
    cartTotal (state) {
      return state.cartList.reduce((sum, item) => sum + item.goods_num, 0)
    },
    // 选中的商品
    selCartList (state) {
      return state.cartList.filter(item => item.isChecked)
    },
    // 选中的商品总数
    selCount (stat, getterse) {
      // 注意: 可以在getters中继续使用其他getters的值
      return getterse.selCartList.reduce((sum, item) => sum + item.goods_num, 0)
    },
    // 选中商品的总价
    selPrice (stat, getters) {
      return getters.selCartList.reduce((sum, item) => {
        return sum + item.goods.goods_price_min * item.goods_num
      }, 0).toFixed(2)
    },
  }
}
<template>
  <div class="cart">
    <div v-if="isLogin && cartList.length > 0">
      <!-- 购物车开头 -->
      <div class="cart-title">
        <span class="all">共<i>{{ cartTotal }}</i>件商品</span>
        ... ...
      </div>

      <div class="footer-fixed">
        ... ...
        <div class="all-total">
          <div class="price">
            <span>合计:</span>
            <span>¥ <i class="totalPrice">{{ selPrice }}</i></span>
          </div>
          <div v-if="!isEdit" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div>
          <div v-else class="delete" :class="{ disabled: selCount === 0 }" >删除</div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
export default {
  computed: {
    ...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice']),
  },
}
</script>
4.0全选反选功能
export default {
  namespaced: true,
  state () {
    return {
      cartList: []
    }
  },
  mutations: {
    // 改变商品的选中状态
    toggleCheck (state, goodsIs) {
      const goods = state.cartList.find((item) => item.goods_id === goodsIs)
      goods.isChecked = !goods.isChecked
    },
    // 改变所有商品的状态
    toggleAllCheck (state, status) {
      state.cartList.forEach(item => {
        item.isChecked = status
      })
    },
  },
  getters: {
    // 是否全选
    isAllChecked (state) {
      return state.cartList.every(item => item.isChecked)
    }
  }
}
<template>
  <div class="cart">
  ... ...
    <div v-if="isLogin && cartList.length > 0">
      <!-- 购物车列表 -->
      <div class="cart-list">
        <div class="cart-item" v-for="item in cartList" :key="item.goods_id">
          <!-- 1, 切换商品选中状态 -->
          <van-checkbox @click="toggleCheck(item.goods_id)"  :value="item.isChecked"></van-checkbox>
          ... ...
        </div>
      </div>

      <div class="footer-fixed">
        <!-- 3, 全选/全不选商品  -->
        <div @click="toggleAllCheck" class="all-check">
          <!-- 2, 如果所有商品都选中, 自动激活全选按钮 -->
          <van-checkbox :value="isAllChecked"  icon-size="18"></van-checkbox>
          全选
        </div>
        ... ...
      </div>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
export default {
  computed: {
    ...mapGetters('cart', ['isAllChecked']),
  },

  methods: {
    // 商品选择框
    toggleCheck (goodsId) {
      this.$store.commit('cart/toggleCheck', goodsId)
    },
    // 商品全选按钮
    toggleAllCheck () {
      this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
    },
  },
}
</script>
5.0修改数量
import request from '@/utils/request'
// 更新购物车商品数量
export const changeCount = (goodsId, goodsNum, goodsSkuId) => {
  return request.post('/cart/update', {
    goodsId,
    goodsNum,
    goodsSkuId
  })
}
import { changeCount } from '@/api/cart'

export default {
  namespaced: true,
  state () {
    return {
      cartList: []
    }
  },
  mutations: {
    // 改变商品数量
    changeGoodsCount (state, obj) {
      const { goodsId, goodsNum } = obj
      const goods = state.cartList.find(item => item.goods_id === goodsId)
      goods.goods_num = goodsNum
    }
  },
  actions: {
    // 修改购物车商品的数量
    async CHANGE_GOODS_NUM ({ commit }, obj) {
      const { goodsId, goodsNum, goodsSkuId } = obj
      // 先更新本地数量
      commit('changeGoodsCount', { goodsId, goodsNum })
      // 再更新后台数量
      await changeCount(goodsId, goodsNum, goodsSkuId)
    },
  },
}
<template>
 <div class="cart-list">
    <div class="cart-item" v-for="item in cartList" :key="item.goods_id">
      ... ...  
      <!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层 -->
      <!-- @input="changeCount" -->
      <CountBox @input="(value) => changeCount(value, item.goods_id, item.goods_sku_id)" :value="item.goods_num"></CountBox>
      ... ...
    </div>
 </div>
</template>

<script>
import { mapGetters, mapState } from 'vuex'
export default {
  methods: {
    // 修改商品数量
    changeCount (goodsNum, goodsId, goodsSkuId) {
      // console.log(goodsNum, goodsId, goodsSkuId)
      // 调用 vuex 的 action,进行数量的修改
      this.$store.dispatch('cart/CHANGE_GOODS_NUM', {
        goodsNum,
        goodsId,
        goodsSkuId
      })
    },
  },
}
</script>

6.0切换状态

点击编辑, 结算模式切换到删除模式, 点击删除可以删除购物车商品, 点击结算跳转到订单结算页面

import request from '@/utils/request'

// 删除购物车商品
export const delSelect = (cartIds) => {
  return request.post('/cart/clear', {
    cartIds
  })
}
import { delSelect } from '@/api/cart'

export default {
  actions: {
    // 删除商品
    async DEL_SELECT_GOODS ({ getters, dispatch }) {
      // 拿到选中的商品id
      const goodsIds = getters.selCartList.map(item => item.id)
      // 删除
      await delSelect(goodsIds)
      // 刷新数据
      dispatch('GET_CART_LIST')
    }
  },
}
<template>
 <div>
      <!-- 购物车开头 -->
      <div class="cart-title">
        <!-- 1, 切换页面模式 -->
        <span class="edit" @click="isEdit = !isEdit">
          <van-icon name="edit" />
          编辑
        </span>
      </div>

      <div class="footer-fixed">
        <div class="all-total">
         ... ...
          <!-- 2, 不同模式显示不同按钮 -->
          <div v-if="!isEdit" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div>
          <div v-else @click="handleDel" class="delete" :class="{ disabled: selCount === 0 }" >删除</div>
        </div>
      </div>
    </div>
</template>

<script>
export default {
  data () {
    return {
      // 页面模式(编辑|删除)
      isEdit: false
    }
  },
  methods: {
    // 4,删除商品
    async handleDel () {
      if (this.selCount === 0) return false
      await this.$store.dispatch('cart/DEL_SELECT_GOODS')
      this.isEdit = false
    },
  },
  watch: {
    // 3,根据页面模式改变商品的选中状态
    isEdit (value) {
      if (value) {
        // 删除模式, 商品全不选
        this.$store.commit('cart/toggleAllCheck', false)
      } else {
        // 结算模式, 商品全选
        this.$store.commit('cart/toggleAllCheck', true)
      }
    }
  }
}
</script>
7.0空购物车
<template>
  <div class="cart">
    <!-- 数据列表 -->
    <div v-if="isLogin && cartList.length > 0">
      ... ...
    </div>

    <!-- 空数据列表 -->
    <div class="empty-cart" v-else>
      <img src="@/assets/empty.png" alt="">
      <div class="tips">
        您的购物车是空的, 快去逛逛吧
      </div>
      <div class="btn" @click="$router.push('/')">去逛逛</div>
    </div>
  </div>
</template>

<script>
export default {
  computed: {
    // 用户是否登录
    isLogin () {
      return this.$store.getters.token
    }
  },
}
</script>

订单结算台

需求

  1. 订单收货地址管理
  2. 封装订单确认接口
  3. 结算台渲染
  4. 购物车结算
  5. 立即购买结算
  6. minxis混入
  7. 提交订单并支付

01.收货地址管理
import request from '@/utils/request'

// 添加地址列表
export const postAddress = (data) => {
  return request({
    headers: {
      'Content-Type': 'application/json;charset=UTF-8'
    },
    url: '/address/add',
    method: 'POST',
    data
  })
}
<template>
   <div>
    <van-nav-bar title="地址管理" left-arrow @click-left="$router.go(-1)" />
    <van-button type="primary" @click="add">添加收获地址</van-button>
   </div>
 </template>

<script>
import { postAddress } from '@/api/address'

export default {
  name: 'AddressIndex',
  methods: {
    // 添加收获地址
    async add () {
      await postAddress({
        form: {
          name: '张小二',
          phone: '18999292929',
          region: [
            {
              value: 782,
              label: '上海'
            },
            {
              value: 783,
              label: '上海市'
            },
            {
              value: 785,
              label: '徐汇区'
            }
          ],
          detail: '北京路1号楼8888室'
        }
      })
    }
  }
}
</script>
02.封装订单确认接口

订单确认成功后,拿到返回值, 渲染到订单结算台

import request from '@/utils/request'

// 订单结算确认
// mode: cart    => obj { cartIds }
// mode: buyNow  => obj { goodsId  goodsNum  goodsSkuId }
export const checkOrder = (mode, obj) => {
  return request.get('/checkout/order', {
    params: {
      mode, // cart(购物车) buyNow(立即购买)
      delivery: 10, // 10 快递配送 20 门店自提
      couponId: 0, // 优惠券ID 传0 不使用优惠券
      isUsePoints: 0, // 积分 传0 不使用积分
      ...obj // 将传递过来的参数对象 动态展开
    }
  })
}
03.结算台渲染
import request from '@/utils/request'

// 获取地址列表
export const getAddressList = () => {
  return request.get('/address/list')
}
}
<template>
   <div class="pay">
    <van-nav-bar fixed title="订单结算台" left-arrow @click-left="$router.go(-1)" />

    <!-- 2,地址相关 -->
    <div class="address" @click="$router.push('/address')">
      <div class="left-icon">
        <van-icon name="logistics" />
      </div>

      <div class="info" v-if="selectedAddress.address_id">
        <div class="info-content">
          <span class="name">{{ selectedAddress.name }}</span>
          <span class="mobile">{{ selectedAddress.phone }}</span>
        </div>
        <div class="info-address">
          {{ longAddress }}
        </div>
      </div>

      <div class="info" v-else>
        请选择配送地址
      </div>

      <div class="right-icon">
        <van-icon name="arrow" />
      </div>
    </div>

    <!-- 3,订单明细 -->
    <div class="pay-list" v-if="order.goodsList">
      <div class="list">
        <div class="goods-item" v-for="item in order.goodsList" :key="item.goods_id">
            <div class="left">
              <img :src="item.goods_image" alt="" />
            </div>
            <div class="right">
              <p class="tit text-ellipsis-2">
                {{ item.goods_name }}
              </p>
              <p class="info">
                <span class="count">x{{ item.total_num }}</span>
                <span class="price">¥{{ item.total_pay_price }}</span>
              </p>
            </div>
        </div>
      </div>

      <div class="flow-num-box">
        <span>共 {{ order.orderTotalNum }} 件商品,合计:</span>
        <span class="money">¥{{ order.orderTotalPrice }}</span>
      </div>

      <div class="pay-detail">
        <div class="pay-cell">
          <span>订单总金额:</span>
          <span class="red">¥{{ order.orderTotalPrice }}</span>
        </div>

        <div class="pay-cell">
          <span>优惠券:</span>
          <span>无优惠券可用</span>
        </div>

        <div class="pay-cell">
          <span>配送费用:</span>
          <span v-if="!selectedAddress">请先选择配送地址</span>
          <span v-else class="red">+¥0.00</span>
        </div>
      </div>

      <!-- 支付方式 -->
      <div class="pay-way">
        <span class="tit">支付方式</span>
        <div class="pay-cell">
          <span><van-icon name="balance-o" />余额支付(可用 ¥ {{ personal.balance }} 元)</span>
          <!-- <span>请先选择配送地址</span> -->
          <span class="red"><van-icon name="passed" /></span>
        </div>
      </div>

      <!-- 买家留言 -->
      <div class="buytips">
        <textarea v-model="remark" placeholder="选填:买家留言(50字内)" name="" id="" cols="30" rows="10"></textarea>
      </div>
    </div>

    <!-- 底部提交 -->
    <div class="footer-fixed">
      <div class="left">实付款:<span>¥{{ order.orderTotalPrice }}</span></div>
      <div class="tipsbtn">提交订单</div>
    </div>
  </div>
</template>

<script>
import { getAddressList } from '@/api/address'

export default {
  name: 'PayIndex',
  data () {
    return {
      addressList: [], // 收获地址
      order: {},
      personal: {},
      remark: '' // 备注留言
    }
  },
  computed: {
    // 选中的默认地址
    selectedAddress () {
      // 这里地址管理非主线业务,直接获取第一个项作为选中的地址
      return this.addressList[0] || {}
    },
    // 完整的收获地址
    longAddress () {
      const region = this.selectedAddress.region
      return region.province + region.city + region.region + this.selectedAddress.detail
    },
    // 拿到订单模式
    mode () {
      return this.$route.query.mode
    },
    // 拿到商品的id
    cartIds () {
      return this.$route.query.cartIds
    },
    // 拿到商品的id
    goodsId () {
      return this.$route.query.goodsId
    },
    // 拿到商品的skuid
    goodsSkuId () {
      return this.$route.query.goodsSkuId
    },
    // 拿到商品数量
    goodsNum () {
      return this.$route.query.goodsNum
    }
  },
  created () {
    // 获取收货地址
    this.getAddressList()
    // 获取订单详情
    this.getOrderList()
  },
  methods: {
    // 1,获取收获地址
    async getAddressList () {
      const { data: { list } } = await getAddressList()
      this.addressList = list
    },
    // 2,获取订单详情
    async getOrderList () {
      // 购物车结算
      if (this.mode === 'cart') {
        const { data: { order, personal } } = await checkOrder(this.mode, {
          cartIds: this.cartIds
        })
        this.order = order
        this.personal = personal
      }
      // 立刻购买结算
      if (this.mode === 'buyNow') {
        const { data: { order, personal } } = await checkOrder(this.mode, {
          goodsId: this.goodsId,
          goodsSkuId: this.goodsSkuId,
          goodsNum: this.goodsNum
        })
        this.order = order
        this.personal = personal
      }
    }
  }
}
</script>
04.购物车结算
<template>
   <div class="all-total">
     ... ...
     <div @click="goPay">结算({{ selCount }})</div>
     ... ...
   </div>
</template>

<script>
export default {
  methods: {
    // 去支付
    goPay () {
      // 判断有没有选中商品
      if (this.selCount > 0) {
        // 有选中的 商品 才进行结算跳转
        this.$router.push({
          path: '/pay',
          query: {
            mode: 'cart',
            // 参数示例: 'cartId,cartId,cartId'
            cartIds: this.selCartList.map(item => item.id).join(',') 
          }
        })
      }
    }
  },
}
</script>
05.立即购买结算
<template>
    <!-- 加入购物车/立即购买 公用的弹层 -->
    <van-action-sheet>
      ... ...
      <div class="showbtn" v-if="detail.stock_total > 0">
          ... ...
          <div class="btn now" v-else @click="goBuyNow">立刻购买</div>
        </div>
    </van-action-sheet>
</template>

<script>
export default {
  methods: {
    // 登录提示
    // 判断是否登录, 登录返回true, 未登录返回false(提示去登录)
    loginConfirm () {
      if (!this.$store.getters.token) {
        // 没有登录, 去登录页
        // 携带参数,用于回跳
        this.$dialog.confirm({
          title: '温馨提示',
          message: '登录后才可以操作购物车',
          confirmButtonText: '去登录',
          cancelButtonText: '再逛逛'
        })
          .then(() => {
            this.$router.replace({
              path: '/login',
              query: {
                backPath: this.$route.fullPath
              }
            })
          })
          .catch(() => {})
        return false
      }

      // 已经登录
      return true
    }
    
    // 立即购买
    goBuyNow () {
      // 判断是否登录
      if (!this.loginConfirm()) {
        return
      }
      // 已经登录
      this.$router.push({
        path: '/pay',
        query: {
          mode: 'buyNow',
          goodsId: this.goodsId,
          goodsSkuId: this.detail.skuList[0].goods_sku_id,
          goodsNum: this.addCount
        }
      })
    }
  }
}
</script>
06.minxis混入
export default {
  // 混入文件里写的就是Vue实例的配置项,可以在需要的文件中使用, 提高代码复用性
  // 支持 data methods computed 生命周期函数 ...
  //  注意:
  //   1, 如果混入文件和组件内, 提供了重名的属性, 组件内属性的优先级更高
  //   2, 如果编写了生命周期函数, 混入文件内和组件内的生命周期函数不会冲突,会被数组管理
  //      统一在组件内执行
  data () {},
  methods: {
    // 登录提示
    // 判断是否登录, 登录返回true, 未登录返回false(提示去登录)
    loginConfirm () {
      if (!this.$store.getters.token) {
        // 没有登录, 去登录页
        // 携带参数,用于回跳
        this.$dialog.confirm({
          title: '温馨提示',
          message: '登录后才可以操作购物车',
          confirmButtonText: '去登录',
          cancelButtonText: '再逛逛'
        })
          .then(() => {
            this.$router.replace({
              path: '/login',
              query: {
                backPath: this.$route.fullPath
              }
            })
          })
          .catch(() => {})
        return false
      }

      // 已经登录
      return true
    }
  }
}
<script>
import loginConfirm from '@/mixins/loginConfirm'

export default {
  name: 'ProDetail',
  mixins: [loginConfirm],
  methods: {
    // 立即购买
    goBuyNow () {
      // 判断是否登录
      if (!this.loginConfirm()) {
        return
      }
    ... ...
    }
  }
}
</script>
07.提交订单并支付
import request from '@/utils/request'
// 提交订单
// mode: cart    => obj { cartIds, remark }
// mode: buyNow  => obj { goodsId, goodsNum, goodsSkuId, remark }
export const submitOrder = (mode, obj) => {
  return request.post('/checkout/submit', {
    mode,
    delivery: 10, // 10 快递配送
    couponId: 0,
    isUsePoints: 0,
    payType: 10, // 余额支付
    ...obj
  })
}
<template>
   <div class="pay">
    <!-- 底部提交 -->
    <div class="footer-fixed">
      ... ...
      <div class="tipsbtn" @click="submitOrder">提交订单</div>
    </div>
  </div>
</template>

<script>
import { submitOrder } from '@/api/order'
export default {
  methods: {
    // 订单支付
    async submitOrder () {
      if (this.mode === 'cart') {
        await submitOrder(this.mode, {
          cartIds: this.cartIds,
          remark: this.remark
        })
      }
      if (this.mode === 'buyNow') {
        await submitOrder(this.mode, {
          goodsId: this.goodsId,
          goodsSkuId: this.goodsSkuId,
          goodsNum: this.goodsNum,
          remark: this.remark
        })
      }
      this.$toast.success('支付成功')
      this.$router.replace('/myorder')
    },
  }
}
</script>

订单管理

import request from '@/utils/request'
// 订单列表
export const getMyOrderList = (dataType, page) => {
  return request.get('/order/list', {
    params: {
      dataType,
      page 
    }
  })
}

<template>
  <div class="order-list-item" v-if="item.order_id">
    <div class="tit">
      <div class="time">{{ item.create_time }}</div>
      <div class="status">
        <span>{{ item.state_text }}</span>
      </div>
    </div>
    <div class="list" >
      <div class="list-item" v-for="(goods, index) in item.goods" :key="index">
        <div class="goods-img">
          <img :src="goods.goods_image" alt="">
        </div>
        <div class="goods-content text-ellipsis-2">
          {{ goods.goods_name }}
        </div>
        <div class="goods-trade">
          <p>¥ {{ goods.total_pay_price }}</p>
          <p>x {{ goods.total_num }}</p>
        </div>
      </div>
    </div>
    <div class="total">
      共 {{ item.total_num }} 件商品,总金额 ¥{{ item.total_price }}
    </div>
    <div class="actions">
      <div v-if="item.order_status === 10">
        <span v-if="item.pay_status === 10">立刻付款</span>
        <span v-else-if="item.delivery_status === 10">申请取消</span>
        <span v-else-if="item.delivery_status === 20 || item.delivery_status === 30">确认收货</span>
      </div>
      <div v-if="item.order_status === 30">
        <span>评价</span>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      default: () => {
        return {}
      }
    }
  }
}
</script>
<template>
  <div class="order">
    <van-nav-bar title="我的订单" left-arrow @click-left="$router.go(-1)" />

    <van-tabs v-model="active" sticky>
      <van-tab name="all" title="全部"></van-tab>
      <van-tab name="payment" title="待支付"></van-tab>
      <van-tab name="delivery" title="待发货"></van-tab>
      <van-tab name="received" title="待收货"></van-tab>
      <van-tab name="comment" title="待评价"></van-tab>
    </van-tabs>

    <OrderListItem v-for="item in list" :key="item.order_id" :item="item"></OrderListItem>
  </div>
</template>

<script>
import OrderListItem from '@/components/OrderListItem.vue'
import { getMyOrderList } from '@/api/order'
export default {
  name: 'OrderPage',
  components: {
    OrderListItem
  },
  data () {
    return {
      active: this.$route.query.dataType || 'all',
      page: 1,
      list: []
    }
  },
  methods: {
    async getOrderList () {
      const { data: { list } } = await getMyOrderList(this.active, this.page)
      list.data.forEach((item) => {
        item.total_num = 0
        item.goods.forEach(goods => {
          item.total_num += goods.total_num
        })
      })
      this.list = list.data
    }
  },
  watch: {
    active: {
      immediate: true,
      handler () {
        this.getOrderList()
      }
    }
  }
}
</script>

个人中心

import request from '@/utils/request'

// 获取个人信息
export const getUserInfoDetail = () => {
  return request.get('/user/info')
}

import { getInfo, setInfo } from '@/utils/storage'

export default {
  namespaced: true,
  state () {
    return {
      // 个人权证相关
      userInfo: getInfo()
    }
  },
  mutations: {
    // 设置用户信息
    SET_USER_INFO (state, userInfo) {
      state.userInfo = userInfo
      setInfo(userInfo) // 存储用户信息到本地
    }
  },
  actions: {
    // 退出登录
    logout ({ commit }) {
      // 清空个人信息
      commit('SET_USER_INFO', {})
      // 情况购物车信息(跨模块调用mutations)
      // commit('模块名/方法名', 传值/null, { root: true(开启全局) })
      commit('cart/setCartList', [], { root: true })
    }
  },
  getters: {}
}
<template>
  <div class="user">
    <div class="head-page" v-if="isLogin">
      <div class="head-img">
        <img src="@/assets/default-avatar.png" alt="" />
      </div>
      <div class="info">
        <div class="mobile">{{ detail.mobile }}</div>
        <div class="vip">
          <van-icon name="diamond-o" />
          普通会员
        </div>
      </div>
    </div>

    <div v-else class="head-page" @click="$router.push('/login')">
      <div class="head-img">
        <img src="@/assets/default-avatar.png" alt="" />
      </div>
      <div class="info">
        <div class="mobile">未登录</div>
        <div class="words">点击登录账号</div>
      </div>
    </div>

    <div class="my-asset">
      <div class="asset-left">
        <div class="asset-left-item">
          <span>{{ detail.pay_money || 0 }}</span>
          <span>账户余额</span>
        </div>
        <div class="asset-left-item">
          <span>0</span>
          <span>积分</span>
        </div>
        <div class="asset-left-item">
          <span>0</span>
          <span>优惠券</span>
        </div>
      </div>
      <div class="asset-right">
        <div class="asset-right-item">
          <van-icon name="balance-pay" />
          <span>我的钱包</span>
        </div>
      </div>
    </div>
    <div class="order-navbar">
      <div class="order-navbar-item" @click="$router.push('/myorder?dataType=all')">
        <van-icon name="balance-list-o" />
        <span>全部订单</span>
      </div>
      <div class="order-navbar-item" @click="$router.push('/myorder?dataType=payment')">
        <van-icon name="clock-o" />
        <span>待支付</span>
      </div>
      <div class="order-navbar-item" @click="$router.push('/myorder?dataType=delivery')">
        <van-icon name="logistics" />
        <span>待发货</span>
      </div>
      <div class="order-navbar-item" @click="$router.push('/myorder?dataType=received')">
        <van-icon name="send-gift-o" />
        <span>待收货</span>
      </div>
    </div>

    <div class="service">
      <div class="title">我的服务</div>
      <div class="content">
        <div class="content-item">
          <van-icon name="records" />
          <span>收货地址</span>
        </div>
        <div class="content-item">
          <van-icon name="gift-o" />
          <span>领券中心</span>
        </div>
        <div class="content-item">
          <van-icon name="gift-card-o" />
          <span>优惠券</span>
        </div>
        <div class="content-item">
          <van-icon name="question-o" />
          <span>我的帮助</span>
        </div>
        <div class="content-item">
          <van-icon name="balance-o" />
          <span>我的积分</span>
        </div>
        <div class="content-item">
          <van-icon name="refund-o" />
          <span>退换/售后</span>
        </div>
      </div>
    </div>

    <div class="logout-btn">
     <button @click="logout">退出登录</button>
    </div>
  </div>
</template>

<script>
import { getUserInfoDetail } from '@/api/user.js'
export default {
  name: 'UserPage',
  data () {
    return {
      detail: {}
    }
  },
  created () {
    if (this.isLogin) {
      this.getUserInfoDetail()
    }
  },
  computed: {
    isLogin () {
      return this.$store.getters.token
    }
  },
  methods: {
    async getUserInfoDetail () {
      const { data: { userInfo } } = await getUserInfoDetail()
      this.detail = userInfo
    },
    logout () {
      this.$dialog.confirm({
        title: '温馨提示',
        message: '你确认要退出么'
      }).then(() => {
        // 退出是一个动作 => 包含了两步,分别是将 user 和 cart 进行重置
        this.$store.dispatch('user/logout')
      }).catch(() => {})
    }
  }
}
</script>

打包优化

vue脚手架只是开发过程中, 协助开发的工具, 开发完成后, 脚手架不参与上线

打包的作用

  1. 将多个文件压缩合并成一个文件
  2. 语法降级
  3. less sass ts语法解析
  4. 打包后, 生成浏览器可以直接运行的网页

打包命令

  1. yarn build
  2. 根目录下常见dist目录, 目录中存放打包后的文件
  3. 默认情况下, 需要放到服务器根目录, 如果需要双击运行, 需要配置相对路径

  1. 配置前的打包文件, 以绝对路径的形式查找资源

  1. 配置后的打包文件, 以相对路径的形式查找资源 (此处省略了 ./)

路由懒加载

当打包构建应用时, JS的会被打包在一起,文件就会很大, 影响页面加载, 可以把不同路由对应的组件分割成不同的代码块,然后当路由被访问时, 才加载对应的代码, 这样加载效率大大提高

步骤

  1. 异步组件改造

  1. 使用异步

  1. 打包结果对比

  • 11
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
【资源说明】 基于vue2和vant-ui构建的前端H5商城源码+项目说明+在线预览.zip基于vue2和vant-ui构建的前端H5商城源码+项目说明+在线预览.zip 在线预览:http://swqjuelian.github.io 改进和优化大致内容如下: 1. 首页:下拉刷新、商品item一键加收藏、加购物车、分享、图片懒加载 2. 商品详情页:增加van的SKU选择面板 3. 分类:图片懒加载、空分类van-empty 4. 搜索页面:支持上拉加载更多数据(van的list组件运用)、支持价格排序(只有价格排序接口...) 5. 购物车:SKU项左滑可一键收藏和删除、商品标签显示。 6. 结算页:添加支付宝、微信支付方式、优惠卷选择组件、收货地址编辑。 7. 提供商品收藏功能:本地localstroage保存数据、支持左滑删除收藏。 8. 提供收货地址列表、收货地址编辑、新增收货地址。 9. 个人中心:添加待评价快捷按钮。 10. 支持评价商品,针对一个订单中的某个SKU分别评价(模仿京东和淘宝) 11. 提供确认收货、取消订单功能。 12. 订单页允许快捷重新将商品加入到购物车(模仿京东和淘宝)。 13. 细节:各个组件一些CSS样式调节(主要是模仿一下京东和淘宝、会额外添加一些按钮图标之类的) 14. keep-alive 缓存首页和分类页。 【备注】 1、该资源内项目代码都经过测试运行成功,功能ok的情况下才上传的,请放心下载使用!有问题请及时沟通交流。 2、适用人群:计算机相关专业(如计科、信息安全、数据科学与大数据技术、人工智能、通信、物联网、自动化、电子信息等)在校学生、专业老师或者企业员工下载使用。 3、用途:项目具有较高的学习借鉴价值,不仅适用于小白学习入门进阶。也可作为毕设项目、课程设计、大作业、初期项目立项演示等。 4、如果基础还行,或热爱钻研,亦可在此项目代码基础上进行修改添加,实现其他不同功能。 欢迎下载,沟通交流,互相学习,共同进步!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值