尚品汇---笔记与思考

day1

1.1 项目结构介绍

node_modules文件夹:项目依赖文件夹

public文件夹:一般放置一些静态资源(图片),需要注意的是:放在public文件夹中的静态资源,webpack进行打包的时候会原封不动的打包到dist文件夹中

src文件夹(程序员源代码文件夹):

        assets文件夹:一般也是放置静态资源(一般放置多个组件共用的静态资源),需要注意:放置在assets文件夹里面的静态资源,在webpack打包的时候,webpack会把静态资源当做一个模块,打包到JS文件中

        components文件夹:一般放置的是非路由组件(全局组件)

        App.vue:唯一的根组件,Vue当中的组件(.vue)

babel.config.js:配置文件(babel相关)

package.json文件:认为是项目的“身份证”,记录项目叫做什么、项目中有哪些依赖、项目怎么运行

package-lock.json:缓存性文件

README.md:说明性文件

1.2 项目配置

项目运行起来的时候,让浏览器自动打开:

找到package.json文件

修改scripts配置项的serve命令(添加--open)

  "scripts": {
    "serve": "vue-cli-service serve --open",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  }

关闭eslint校验:

        添加下列语句即可

  lintOnSave:false

src文件夹简写方法:配置别名 @(在jsconfig.json文件夹中)

webstorm里自带下列代码,有需要的朋友可以复制一下放到jsonconfig.json中

@代表的是src文件夹,这样将来即使文件过多,找的时候也很方便

  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "baseUrl": "./",
    "moduleResolution": "node",
    "paths": {
      "@/*": [
        "src/*"
      ]
    }

1.3 路由分析

项目路由的分析:vue-router

前端所谓路由:KV键值对

key:URL(地址栏中的路径)

value:相应的路由组件

项目为上中下结构

路由组件:Home首页路由组件、Search路由组件、Login登录组件、Register注册组件

非路由组件:Header头部组件(首页、搜索页)、Footer尾部组件(在首页、搜索页有,但是在登录页面是没有的)

开发项目的顺序:

        1.书写静态页面(HTML+CSS)

        2.拆分组件

        3.获取服务器的数据动态展示

        4.完成相应的动态业务逻辑

路由组件的搭建

下载vue-router:注意vue2对应vue-router@3,vue3对应vue-router@4

由上述分析,路由组件应该有四个:Home、Search、Login、Register

components文件夹:经常放置的是非路由组件(共用全局组件)

pages|views文件夹:经常放置路由组件

        配置路由:项目中配置的路由一般放置在router文件夹中

总结:

路由组件与非路由组件的区别:

        1.路由组件一般放置在pages|views文件夹下,非路由组件一般放置在components文件夹中

        2.路由组件一般需要在router文件夹中进行注册(使用的即为组件的名字),非路由组件在使用的时候,一般都是以标签的形式使用

        3.注册完路由,不管路由组件、还是非路由组件,身上都有$route、$router属性

        $route:一般用于获取路由信息(路径、query、params等等)

        $router:一般进行编程式导航,进行路由跳转(push、replace)

路由的跳转:

        路由的跳转有两种形式:

                声明式导航router-link,可以进行路由的跳转

                编程式导航push|replace,可以进行路由跳转

        编程式导航:

                声明式导航能做的,编程式导航都能做

                但是编程式导航除了可以进行路由跳转,还可以做一些其他的业务处理

    //搜索按钮的回调函数,需要向search路由进行跳转
    goSearch(){
      this.$router.push('/search')
    }

        声明式导航:

<router-link to="/login">登录</router-link>

1.4 Footer组件的显示与隐藏

显示或者隐藏组件:v-if|v-show

Footer组件:在Home、Search显示Footer组件

Footer组件:在登录、注册的时候隐藏

  <Footer v-show="$route.path=='/home'||$route.path=='/search'"/>

我们可以根据组件身上的$route获取当前路由信息,通过路由路径判断Footer的显示与隐藏

在配置路由的时候,可以给路由添加路由元信息[meta]

    routes:[
        {
            path:'/home',
            component:Home,
            meta:{show:true}
        },
        {
            path:"/search",
            component:Search,
            meta:{show:true}
        },
        {
            path:"/login",
            component:Login,
            meta:{show:false}
        },
        {
            path:"/register",
            component:Register,
            meta:{show:false}
        },
        //重定向:在项目跑起来的时候,访问"/",立马让它定向到首页
        {
            path:'*',
            redirect:"/home"
        }
    ]
  <Footer v-show="$route.meta.show"/>

1.5 路由传参

路由跳转有几种方式?

比如:A->B

声明式导航:router-link(务必要有to属性),可以实现路由的跳转

编程式导航:利用的是组件实例的$router.push|$router.replace方法,可以实现路由的跳转(可以书写一些自己的业务)

路由传参,参数有几种写法?

params参数:属于路径当中的一部分。需要注意,在配置路由的时候,需要占位

        {
            path:"/search/:keyword",
            component:Search,
            meta:{show:true}
        }

query参数:不属于路径当中的一部分,类似于ajax中的queryString   /home?k=v&k=v,不需要占位

路由传递参数:

字符串形式:

      this.$router.push('/search/'+this.keyword+"?k="+this.keyword.toUpperCase())

模板字符串形式:

      this.$router.push(`/search/${this.keyword}?k=${this.keyword.toUpperCase()}`)

对象的形式:

注意:使用对象的形式时,需要给要跳转的路由命名,而不能使用路径来跳转

      this.$router.push({
        name:'search',
        params:{
          keyword:this.keyword
        },
        query:{
          k:this.keyword.toUpperCase()
        }
      })

1.6 面试题

面试题1:路由传递参数(对象写法)path是否可以结合params参数一起使用?

不能,路由跳转传参的时候,对象的写法可以是name、path形式,但需要注意的是,path这种写法不能和params参数一起写

面试题2:如何指定params参数可传可不传?

如果路由要求传递params参数,但你不传,那么url会出现问题

如果想要指定params参数可传可不传,需要在配置路由的时候在占位的后面加上一个问号

        {
            name:'search',
            path:"/search/:keyword?",
            component:Search,
            meta:{show:true}
        }

面试题3:params参数可以传递也可以不传递,但如果传递的是空串,如何解决?

使用undefined解决:params参数可以传递、不传递或传递空的字符串

      this.$router.push({
        name:'search',
        params:{keyword:""||undefined},
        query:{k:this.keyword.toUpperCase()}
      })

面试题4:路由组件能不能传递props数据?

布尔值写法:只能传递params参数

        props:true

对象写法:额外的给路由组件传递一些props

        props:{a:1,b:2}

函数写法:可以params参数、query参数,通过props传递给路由组件

        props:($route)=>{

                return {keyword:$route.params.keyword,k:$route.query.k}

        }

day2

2.1 重写push和replace方法

编程式路由跳转到当前路由(参数不变),多次执行会抛出NavigationDuplicated的警告错误

 首先,底层push方法会返回一个Promise对象

function push(){
    return new Promise((resolve,reject)=>{

    })
}

解决方法:

1.通过给push方法传递相应的成功、失败的回调函数,可以捕获到当前的错误并解决

这种写法治标不治本,将来在别的组件当中push|replace,编程式导航还有类似错误

      this.$router.push({
        name:'search',
        params:{keyword:this.keyword},
        query:{k:this.keyword.toUpperCase()},
      },()=>{},()=>{})
   

2.

this:当前组件实例(search)

this.$router属性:当前的这个属性,属性值VueRouter类的一个实例,当在入口文件注册路由的时候,给组件实例添加$router|$route属性

      function VueRouter(){

      }
      //原型对象的方法
      VueRouter.prototype.push=function(){
        //函数的上下文为VueRouter类的一个实例
      }
      let $router=new VueRouter()
      $router.push(xxx)

重写方法:

//先把VueRouter原型对象的push保存一份
let originPush=VueRouter.prototype.push

//重写push|replace
//第一个参数:告诉原来的push方法,你往哪里跳转(传递哪些参数
VueRouter.prototype.push=function(location,resolve,reject){
    if(resolve&&reject){
        //call|apply区别
        //相同点:都可以调用函数依次,都可以篡改函数的上下文一次
        //不同点:call与apply传递参数:call传递参数用逗号隔开,apply方法执行传递数组
        originPush.call(this,location,resolve,reject)
    }else{
        originPush.call(this,location,()=>{},()=>{})
    }
}

2.2 三级联动组件

由于三级联动,在Home、Search、Detail中都有使用,所以把三级联动注册为全局组件

好处:只需要注册一次,就可以在项目的任何地方使用

全局组件的注册:

        第一个参数:全局组件的名字

        第二个参数:指定是哪一个组件

Vue.component(TypeNav.name,TypeNav)

2.3 axios的二次封装

为什么要对axios进行二次封装?

请求拦截器、响应拦截器:请求拦截器,可以在发请求之前处理一些业务

                                           响应拦截器,当服务器数据返回以后,可以处理一些事情

在项目中API文件夹常用来放置axios相应文件

接口当中,路径都带有/api

        ==>baseURL:"/api"

import axios from "axios";

const requests = axios.create({
    baseURL:"/api",
    timeout:5000
})

//请求拦截器
requests.interceptors.request.use((config)=>{
    //config是headers的请求头

    return config
})

//响应拦截器
requests.interceptors.response.use((res)=>{
    //成功的回调函数:服务器响应数据回来以后,响应拦截器可以检测到,并做一些处理
    return res.data
    },
    (error)=>{
        //响应失败的回调函数
        return Promise.reject(new Error(error))
    })


export default requests

2.4 跨域问题

什么是跨域?

        协议、域名、端口号不同的请求,称之为跨域

http://localhost:8080/#/home        ---前端项目本地服务器

http://39.98.123.211        ---后台服务器

  //配置代理跨域
  devServer:{
    proxy:{
      "/api":{
        target:"http://gmall-h5-api.atguigu.cn",
      }
    }
  }

2.5 nprogress进度条的使用

//引入进度条
import nprogress from 'nprogress'
//引入进度条的样式
import 'nprogress/nprogress.css'

进度条的开始:

    nprogress.start()

进度条的结束:

    nprogress.end()

2.6 vuex状态管理库

vuex是什么?

        vuex是官方提供的一个插件,状态管理库,集中式管理项目中共用的数据

2.7 mapState

mapState传入参数是一个对象,对象的键值对中,值是一个函数。当时用这个计算属性的时候,右侧的函数会立即执行一次

函数注入一个参数为state,即为大仓库中的数据

    ...mapState({
      categoryList:(state)=>{
        return state.typeNav.categoryList
      }
    })

2.8 三级联动动态背景颜色改变

动态添加类:

          <div class="item"
               v-for="(c1,index) in categoryList"
               :key="c1.categoryId"
                :class="{cur:currentIndex===index}"
          >

currentIndex的改变,初始值为-1:

    changeIndex(index){
      this.currentIndex=index
    },
    leaveIndex(){
      this.currentIndex=-1
    }

类:

        .cur{
          background-color: skyblue;
        }

2.9 通过JS控制二三级分类的显示与隐藏

            <div class="item-list clearfix" 
                 :style="{display:currentIndex===index?'block':'none'}">

2.10 演示卡顿现象并引入防抖和节流

卡顿现象:事情触发非常频繁,而且每一次的触发,回调函数都要去执行(如果时间很短,而回调函数内部有计算,那么很有可能出现浏览器卡顿)

函数的节流与防抖:

节流:在规定的间隔时间范围内不会重复处罚回调,只有大于这个时间才会触发回调,把频繁触发变为少量触发

防抖:前面所有的触发都被取消,最后一次执行在规定的时间之后才会触发,也就是说如果连续快速的触发,则只会执行一次

插件lodash:封装了函数的防抖与节流业务

三级联动节流:

引入throttle函数:

import throttle from 'lodash/throttle'

使用节流函数throttle:

    changeIndex:throttle(function(index){
      this.currentIndex=index
    },50),

day3

3.1 三级联动组件的路由跳转与参数传递

三级联动用户可以点击:一级分类、二级分类、三级分类

当你点击的时候,Home模块跳转到Search模块,一级会把用户选中的产品(产品的名字、产品的ID)在路由跳转的时候,进行传递

router-link是一个组件,当服务器的数据返回值后,会循环出很多的router-link组件实例

创建组件实例的时候,一瞬间创建1000+组件实例是很耗用内存的,因此可能出现卡顿现象

最好的解决方案:编程式导航+事件委派 实现路由的跳转和参数的传递

利用事件委派存在的一些问题:

        1.点击的不一定是a标签

                事件委派是把所有的子节点(h3/dt/dl/em)的时间委派给父亲结点。而只有点击a标签的时候,才会进行路由跳转,但如何确定点击的一定是a标签呢?

                即使你能确定点击的是a标签,如何区分是一级、二级还是三级分类的a标签呢?

        2.如何获取参数【1/2/3级分类的产品名字、id】

解决方案:

        1.把子节点当中的a标签加上自定义属性data-categoryName,而其他的子节点是没有的

<a :data-categoryName="c1.categoryName">{{c1.categoryName}}</a>

        2.给每一个a标签添加上对应的data-category1Id/data-category2Id/data-category3Id,用来区分不同级别的分类

总的解决方案:

goSearch(event){
      let {categoryname,category1id,category2id,category3id} = event.target.dataset
      if(categoryname){
        //整理路由跳转的参数
        let location={name:'search'}
        let query={categoryName:categoryname}
        if(category1id){
          query.categoryName=category1id
        }else if(category2id){
          query.categoryName=category2id
        }else{
          query.categoryName=category3id
        }
        //整理参数
        location.query=query
        //路由跳转
        this.$router.push(location)
      }
    }

day4

4.1 三级联动效果在不同路由组件中的显示情况

需求:

在home组件下,会默认显示二级菜单和三级菜单

但search组件下,只默认显示一级菜单

解决方案:

通过v-show动态控制二级菜单和三级菜单的显示与隐藏(v-show="show")

通过路由控制,决定show属性的true/false

        原理:每跳转到一个组件,如果有TypeNav,都会重新创建一个TypeNav的实例,也就是说mounted函数会再次执行。可以将路由判断的逻辑写在mounted钩子里

  mounted(){
    //通知Vuex发送请求,获取数据,存储于仓库之中
    this.$store.dispatch('categoryList')
    if(this.$route.path!='/home'){
      this.show=false
    }
  }

在鼠标滑过“全部商品分类”时,应该有对应的二级分类、三级分类的展示

        对于home组件,鼠标离开时二级分类不会消失

        对于search组件,鼠标离开时只展示一级分类

逻辑判断:

TypeNav模块:

      <div  @mouseleave="leaveShow" @mouseenter="enterShow">
    enterShow(){
      this.show=true
    },
    leaveShow(){
      this.currentIndex=-1
      if(this.$route.path!='/home'){
        this.show=false
      }
    }

4.2 search模块中三级路由组件的过渡效果

过渡动画:前提是组件/元素务必要有v-if/v-show指令,才可以进行过渡动画

        <!--过渡动画-->
        <transition name="sort">
          <div class="sort" v-show="show">
             ……中间内容省略
          </div>
        </transition>
    //过渡动画的样式
    .sort-enter{
      height:0;
    }
    .sort-enter-to{
      height:461px;
    }
    .sort-enter-active{
      transition:all .7s linear
    }

4.3 TypeNav商品分类列表的优化

由于每次home/search组件的销毁和挂载都会导致TypeNav的销毁和挂载,即都会执行mounted函数,从而频繁地向服务器发送请求。但是三级联动组件获取到的内容是不变的,所以可以做优化

可以将三级联动的数据请求放在App.vue的钩子函数中:

  mounted(){
    //通知Vuex发送请求,获取数据,存储于仓库之中
    this.$store.dispatch('categoryList')
  }

4.4 合并参数

捎带params参数:

        //判断:如果路由跳转的时候带有params参数,也要捎带传递过去
        if(this.$route.params){
          location.params=this.$route.params
        }

合并参数:

    //搜索按钮的回调函数,需要向search路由进行跳转
    goSearch(){
      let location={
        name:'search',
        params:{keyword:this.keyword||undefined}
      }
      if(this.$route.query){
        location.query=this.$route.query
      }
      this.$router.push(location)
    }

4.5 mockjs模拟数据

开发Home首页当中的ListContainer组件与Floor组件:

        因为服务器返回的数据只有商品分类和菜单分类的数据,对于ListContainer组件与Floor组件数据服务器是没有提供的。可以通过mockjs模拟

使用步骤:

1)在项目当中src文件夹中创建mock文件夹

2)第二步准备JSON数据(mock文件夹中创建相应的JSON文件)

3)把mock数据需要的图片放置到public文件夹中(public文件夹在打包的时候,会把相应资源原封不动地放在dist文件夹下)

4)创建mockServer.js,开始实现mock虚拟数据

5)把mockServer.js文件在入口文件中引入(至少需要执行一次,才能模拟数据)

问:JSON数据格式文件根本没有对外暴露,为什么可以直接引入?

答:webpack默认对外暴露图片和JSON数据格式文件

Mock.mock()函数有两个参数

        第一个参数是请求的地址

        第二个参数是请求的数据

//先引入mockjs模板
import Mock from 'mockjs'
//把JSON数据格式引入进来
import banner from './banner.json'
import floor from './floor.json'

Mock.mock("/mock/banner",{
    code:200,
    data:banner
})
Mock.mock("/mock/floor",{
    code:200,
    data:floor
})

封装mockServer.js

import axios from 'axios'
import nprogress from 'nprogress'
import 'nprogress/nprogress.css'

let requests=axios.create({
    baseURL:"/mock",
    timeout:5000
})

requests.interceptors.request.use((config)=>{
    nprogress.start()
    return config
})

requests.interceptors.response.use(
    (res)=>{
        nprogress.done()
        return res.data
    },
    (err)=>{
        return Promise.reject(new Error(err))
    }
)

export default requests

发送请求的函数:

//获取banner(Home首页轮播图接口)
export const reqGetBannerList=()=>mockRequest.get('/banner')
//获取floor
export const reqGetFloor=()=>mockRequest.get('/floor')

获取banner轮播图的数据:

vuex处理:

import { reqGetBannerList} from "@/api";

const state={
    bannerList:[]
}
const mutations={
    GETBANNERLIST(state,bannerList){
        state.bannerList=bannerList
    }
}
const actions={
    async getBannerList({commit}){
       let result=await reqGetBannerList()
        if(result.code===200){
            commit('GETBANNERLIST')
        }
    }
}
const getters={}

export default{
    state,mutations,actions,getters
}

获取数据:

  computed:{
    ...mapState({
      bannerList:state=>state.home.bannerList
    })
  }

4.6 swiper

第一步:引包(引入相应的JS/CSS)

在main.js引入一次即可:

import 'swiper/css/swiper.css'
import 'swiper/js/swiper'

第二步:页面中的结构

            <div class="swiper-slide"
                 v-for="(carousel,index) in bannerList" :key="carousel.id"
            >
              <img :src="carousel.imgUrl"/>
            </div>

第三步:new Swiper实例,给轮播图添加动态效果

          new Swiper(document.querySelector('.swiper-container'),{
            loop:true,
            pagination:{
              el:'.swiper-pagination',
              //点击小球的时候也切换图片
              clickable:true
            },
            //前进后退的按钮
            navigation:{
              nextEl:".swiper-button-next",
              prevEl:".swiper-button-prev"
            }
          })

安装swiper5版本比较稳定

4.7 Banner轮播图的实现

首先需要明确,如果直接将swiper实例写在mounted函数中,尽管mounted是在页面结构加载完成后执行,但此时从服务器拿到的数据还没有放到仓库中,故swiper实例中拿不到bannerList的数据

解决方式一:

可以通过setTimeout等待一段时间,这段时间里从服务器拿到的数据就已经放到仓库中了

    setTimeout({
      //swiper实例的实现
    },2000)

解决方式二:

watch+nextTick

watch:数据监听,监听已有数据的变化

        监听bannerList数据的变化:从空数组变为数组里有四个元素

        如果执行handler方法,代表组件实例身上的这个属性的属性值已经有了

但是仅有watch是不够的,因为数据更新了,但是页面渲染可能还没结束

nextTick:

        Vue.nextTick([callback,context])

        用法:在下次DOM更新循环结束之后执行延迟回调。在修改数据之后立刻使用这个方法,获取更新后的DOM

        $nextTick可以保证页面中的结构一定是有的,经常和很多需要DOM已经存在才能实现功能的插件一起使用

watch+nextTick实现功能:

  watch:{
    bannerList:{
      handler(newValue,oldValue){
        this.$nextTick(()=>{
          var mySwiper=new Swiper(document.querySelector('.swiper-container'),{
            loop:true,
            pagination:{
              el:'.swiper-pagination',
              //点击小球的时候也切换图片
              clickable:true
            },
            //前进后退的按钮
            navigation:{
              nextEl:".swiper-button-next",
              prevEl:".swiper-button-prev"
            }
          })
        })
      }
    }
  }

4.8 通过ref获取要操作的DOM

        <div class="swiper-container" ref="mySwiper">
          new Swiper(this.$refs.mySwiper,{

day5

5.1 开发floor组件

由于Floor组件在Home中被调用两次,如果是在Floor组件中触发getFloorList,很难实现两次调用数据不同

        故应该在Home组件中触发

v-for可以在自定义组件上使用

组件间通信的方式有哪些?

props:用于父子组件通信

自定义事件:@on 与 @emit

全局事件总线:$bus 全能

pubsub-js:vue中几乎不用(react使用的较多)

插槽

vuex

List组件中的轮播图,是当前组件内部发送请求、动态渲染解构服务器返回的数据,因此必须使用watch+nextTick

Floor组件中的轮播图,请求由父组件Home发送,并且数据是父组件发送过来的,此时结构已经完全解析好了,所以可以在mounted里写轮播图实例

  mounted(){
    new Swiper(this.$refs.cur,{
      loop:true,
      pagination:{
        el:'.swiper-pagination',
        //点击小球的时候也切换图片
        clickable:true
      },
      //前进后退的按钮
      navigation:{
        nextEl:".swiper-button-next",
        prevEl:".swiper-button-prev"
      }
    })
  }

5.2 共用组件Carousel

切记:以后在开发项目的时候,如果看到某一个组件在很多地方都使用,可以把它变成全局组件

<template>
  <div class="swiper-container" ref="cur">
    <div class="swiper-wrapper">
      <div class="swiper-slide"
           v-for="(carousel,index) in list" :key="carousel.id"
      >
        <img :src="carousel.imgUrl">
      </div>
    </div>
    <!-- 如果需要分页器 -->
    <div class="swiper-pagination"></div>

    <!-- 如果需要导航按钮 -->
    <div class="swiper-button-prev"></div>
    <div class="swiper-button-next"></div>
  </div>
</template>

<script>
import Swiper from "swiper";

export default {
  name: "Carousel",
  props:['list'],
  watch:{
    list:{
      immediate:true,
      handler(){
        this.$nextTick(()=>{
          new Swiper(this.$refs.cur,{
            loop:true,
            autoplay:true,
            pagination:{
              el:'.swiper-pagination',
              //点击小球的时候也切换图片
              clickable:true
            },
            //前进后退的按钮
            navigation:{
              nextEl:".swiper-button-next",
              prevEl:".swiper-button-prev"
            }
          })
        })
      }
    }
  }
}
</script>

<style scoped>

</style>

5.3 search仓库(主讲getters)

getters,相当于计算属性

项目中getters的主要作用:简化仓库中的数据(简化数据而生)

可以把我们将来在组件当中需要用的数据简化一下【将来组件获取数据就简单多了】

数据的返回:

以goodsList为例,如果服务器数据回来了,返回一个数组

如果网络不给力/没有网,state.searchList.goodsList返回的就是undefined

const getters={
    goodsList(state){
        return state.searchList.goodsList||[]
    },
    trademarkList(state){
        return state.searchList.trademarkList||[]
    },
    attrsList(state){
        return state.searchList.attrsList||[]
    }
}

与getters对应的是mapGetters,此函数接收一个数组,不像state那样划分模块

  computed:{
    ...mapGetters(['goodsList'])
  }

5.4 Search中的数据处理

使用Object.assign方法,快速合并数据

在beforeMount里整理数据,在mounted发送请求

export default {
  name: 'Search',

  components: {
    SearchSelector
  },

  data(){
    return {
      //带给服务器的参数
      searchParams:{
        category1Id:"",
        category2Id:"",
        category3Id:"",
        categoryName:"",
        keyword:"",
        order:"",
        pageNo:1,
        pageSize:3,
        props:[],
        trademark:""
      }
    }
  },

  beforeMount(){
    Object.assign(this.searchParams,this.$route.query,this.$route.params)
  },

  mounted(){
    this.getData()
  },

  methods:{
    getData(){
      this.$store.dispatch('getSearchList',this.searchParams)

    }
  },

  computed:{
    ...mapGetters(['goodsList'])
  }

}

5.5 监听路由变化再次发送请求数据

当点击三级联动组件或者搜索框时,路径中的query或params会发生改变。那么路由就会改变,监听路由即可。

每一次请求完毕,应该把相应的1/2/3级分类的id只看,让它接收下一次相应的1/2/3级分类

分类名字和关键字不用清理:因为每一次路由发生变化,都会赋予它新的数据

<!--            分类的面包屑-->
           <li class="with-x" v-if="searchParams.categoryName">{{searchParams.categoryName}} <i @click="removeCategoryName">x</i></li>
  watch:{
    $route(newValue,oldValue){
      Object.assign(this.searchParams,this.$route.query,this.$route.params)
      this.getData()
      this.searchParams.category1Id=''
      this.searchParams.category2Id=''
      this.searchParams.category3Id=''
    }
  }

5.6 动态删除面包屑

即使带给服务器的参数是为空的字符串,仍然会把相应的字段带给服务器

但如果把相应的字段变为undefined,当前这个字段就不会带给服务器了(性能优化)

骚操作:

        对自己所在组件进行路由跳转,可以清除地址栏的指定参数(比如query参数)

    //删除分类的名字
    removeCategoryName(){
      //把带给服务器的参数置空,并给服务器发请求
      this.searchParams.categoryName=undefined
      this.searchParams.category1Id=undefined
      this.searchParams.category2Id=undefined
      this.searchParams.category3Id=undefined
      this.getData()
      this.$router.push({name:'search',params:this.$route.params})
    }

5.6 动态开发面包屑中的分类名

<!--          关键字的面包屑-->
          <li class="with-x" v-if="searchParams.keyword">{{searchParams.keyword}} <i @click="removeQueryName">x</i></li>
    removeQueryName(){
      this.searchParams.keyword=undefined
      this.getData()

    }

5.7 动态开发面包屑中的关键字

当面包屑中的关键字清除以后,需要让兄弟组件Header组件中的关键字清除

设计组件间通信:

props:父子

自定义事件:子父

vuex:仓库数据

插槽:父子

$bus:全局事件总线

注册全局事件总线:

new Vue({
  render: h => h(App),
  beforeCreate(){
    Vue.prototype.$bus=this
  },
  router,
  store,
}).$mount('#app')

在Search组件中触发事件:

      this.$bus.$emit('clear')

在Header组件中绑定事件:

  mounted(){
    this.$bus.$on("clear",()=>{
      this.keyword=""
    })
  }

更新params参数:(Search组件)

      this.$router.push({name:'search',query:this.$route.query})

5.8 面包屑处理品牌信息

在Search组件中展示品牌的面包屑:

注意trademark.tmName的展示方式

<!--         品牌的面包屑-->
            <li class="with-x" v-if="searchParams.trademark">{{searchParams.trademark.split(":")[1]}} <i @click="removeTrademark">x</i></li>

子父组件间通信:

父传子自定义事件:

        <!--selector-->
        <SearchSelector @trademarkInfo="trademarkInfo"/>

由于传递过来的trademark是个对象,而传递给服务器的trademark是个字符串,所以要进行字符串拼接

    trademarkInfo(trademark){
      console.log(trademark)
      this.searchParams.trademark=`${trademark.tmId}:${trademark.tmName}`
      this.getData()
    }

子触发父中的函数:

    trademarkHandler(trademark){
      this.$emit('trademarkInfo',trademark)
    }

5.9 平台售卖属性的操作

父组件:

<!--            平台售卖的属性值展示-->
            <li class="with-x" v-for="(attrValue,index) in searchParams.props" :key="index">{{attrValue.split(":")[1]}} <i @click="removeAttr(index)">x</i> </li>
          </ul>
    attrInfo(attr,attrValue){
      let props=`${attr.attrId}:${attrValue}:${attr.attrName}`
      //数组去重
      if(this.searchParams.props.indexOf(props)===-1)
        this.searchParams.props.push(props)
    },
    removeAttr(index){
      //再次整理参数
      this.searchParams.props.splice(index,1)
      //再次发送请求
      this.getData()
    }

父子组件间通信:

          <!--selector-->
        <SearchSelector @trademarkInfo="trademarkInfo" @attrInfo="attrInfo"/>
    attrInfo(attr,attrValue){
      this.$emit("attrInfo",attr,attrValue)
    }

day6

6.1 排序操作(上)

排序方式:

        1:综合

        2:价格

        asc:升序

        desc:降序

①order属性的属性值最多有4种写法:

1:asc

1:desc

2:asc

2:desc

        //排序的初始状态:综合:降序
        order:"1:desc",

②综合和价格谁应该具有类名active?

        通过order属性值当中是包含1还是包含2来判断

              <ul class="sui-nav">
                <li :class="{active:searchParams.order.indexOf('1')!==-1}">
                  <a href="#">综合</a>
                </li>
                <li :class="{active:searchParams.order.indexOf('2')!==-1}">
                  <a href="#">价格</a>
                </li>
              </ul>

由于模板中不建议使用太长的表达式语句,可以用计算属性书写:

              <ul class="sui-nav">
                <li :class="{active:isOne}">
                  <a href="#">综合</a>
                </li>
                <li :class="{active:isTwo}">
                  <a href="#">价格</a>
                </li>
              </ul>
  computed:{
    ...mapGetters(['goodsList']),
    isOne(){
      return this.searchParams.order.indexOf('1')!==-1
    },
    isTwo(){
      return this.searchParams.or.indexOf('2')!==-1
    }
  }

③综合和价格,谁应该有箭头?

        谁有类名active,谁就有箭头

④箭头用什么制作?

        阿里图标库iconfont

在复制的地址前加 https:

放入index.html静态页面即可使用

      <link rel="stylesheet" href="https://at.alicdn.com/t/c/font_3941403_5qqhi0nusus.css">

图标的基本使用:

              <ul class="sui-nav">
                <li :class="{active:isOne}">
                  <a href="#">综合 <span v-show="isOne" class="iconfont icon-up-arrow"></span></a>
                </li>
                <li :class="{active:isTwo}">
                  <a href="#">价格 <span v-show="isTwo" class="iconfont icon-arrow_down"></span></a>
                </li>
              </ul>

通过计算属性动态决定箭头上下:

    isAsc(){
      return this.searchParams.order.indexOf("asc")!==-1
    },
    isDesc(){
      return this.searchParams.order.indexOf("desc")!==-1
    }
              <ul class="sui-nav">
                <li :class="{active:isOne}">
                  <a href="#">综合 <span v-show="isOne" class="iconfont" :class="{'icon-up-arrow':isAsc,'icon-arrow_down':isDesc}"></span></a>
                </li>
                <li :class="{active:isTwo}">
                  <a href="#">价格 <span v-show="isTwo" class="iconfont" :class="{'icon-up-arrow':isAsc,'icon-arrow_down':isDesc}"></span></a>
                </li>
              </ul>

6.2 排序操作(下)

设定改变上下箭头的规则:

    changeOrder(flag){
      let originFlag=this.searchParams.order.split(":")[0]
      let originSort=this.searchParams.order.split(":")[1]
      let newOrder=''
      if(flag===originFlag){
        originSort=originSort==="desc"?"asc":"desc"
        newOrder=`${originFlag}:${originSort}`
      }else{
        newOrder=`${flag}:desc`
      }
      this.searchParams.order=newOrder
      this.getData()
    }

大家注意newOrder=`${flag}:desc`中的冒号一定要用英文字符,否则切换失败!

day7

7.1 分页功能分析

为什么很多项目采用分页功能?

        因为比如电商平台同时展示的数据有很多(1w+),需要采用分页功能一次加载少量数据,防止卡顿

分页器展示,需要哪些数据(条件)?

        需要知道当前是第几页:pageNo字段代表当前页数

        需要知道每一页需要展示多少条数据:pageSize字段进行代表

        需要知道整个分页器一共有多少条数据:total字段进行代表---【获取另外一条信息:总共有多少页】

        需要知道分页器连续页面个数:5|7(一般是奇数,对称比较好看)

7.2 分页器起始与结束数字计算

①总页数小于连续页数

        仅展示总页数即可

②总页数大于/等于连续页数

根据当前页算起始、结束页码:

        i.起始页码小于1,此时置起始页码为1,结束页码为连续页码数

        ii.结束页码大于总页码,此时置结束页码为总页码,起始页码为:总页码-连续页码+1

        iii.正常情况下,直接计算

  computed:{
    totalPage(){
      return Math.ceil(this.total/this.pageSize)
    },
    startNumAndEndNum(){
      let start=1,end=1;
      //不正常现象:总页数没有连续页码数多
      if(this.continues>this.totalPage){
        end=this.totalPage
      }else{
        start=this.pageNo-parseInt(this.continues)/2
        end=this.pageNo+parseInt(this.continues)/2
        if(start<1){
          start=1
          end=this.continues
        }
        if(end>this.totalPage){
          start=end-this.continues+1
          end=this.totalPage
        }
      }
      return {start,end}
    }

7.3 分页器的动态展示

v-for也可以用于循环数字。指定要循环的数字,比如说10

则v-for会遍历0~10

如果希望从指定的数字开始遍历,可以用v-if加以限制

<template>
  <div class="fr page">
    <div class="sui-pagination clearfix">
      <ul>
        <li class="prev disabled">
          <a href="#">«上一页</a>
        </li>
        <li class="active" v-if="startNumAndEndNum.start>1">
          <a href="#">1</a>
        </li>
        <li>
          <a href="#" v-if="startNumAndEndNum.start>2">...</a>
        </li>
        <li v-for="(page,index) in startNumAndEndNum.end" :key="index" v-if="page>=startNumAndEndNum.start">
          <a href="#">{{page}}</a>
        </li>
        <li class="dotted" v-if="startNumAndEndNum.end<(totalPage-1)"><span>...</span></li>
        <li>
          <a href="#" v-if="startNumAndEndNum.end<totalPage">{{totalPage}}</a>
        </li>
        <li>
          <a href="#">{{totalPage}}</a>
        </li>
        <li class="next">
          <a href="#">下一页»</a>
        </li>
      </ul>
      <div><span>共{{total}}条&nbsp;</span></div>
    </div>
  </div>
</template>

7.4 分页器的完成

<template>
  <div class="fr page">
    <div class="sui-pagination clearfix">
      <ul>
        <li class="prev" :disabled="pageNo===1" @click="$emit('getPageNo',pageNo-1)">
          <a>«上一页</a>
        </li>
        <li class="active" v-if="startNumAndEndNum.start>1" @click="$emit('getPageNo',1)">
          <a>1</a>
        </li>
        <li>
          <a v-if="startNumAndEndNum.start>2">...</a>
        </li>
        <li
            v-for="(page,index) in startNumAndEndNum" :key="index"
            v-if="page>=startNumAndEndNum.start"
            @click="$emit('getPageNo',page)"
        >
          <a>{{page}}</a>
        </li>
        <li class="dotted" v-if="startNumAndEndNum.end<(totalPage-1)"><span>...</span></li>
        <li>
          <a
              v-if="startNumAndEndNum.end<totalPage"
              @click="$emit('getPageNo',totalPage)"
          >{{totalPage}}</a>
        </li>
        <li>
          <a>{{totalPage}}</a>
        </li>
        <li class="next" :disabled="pageNo===totalPage" @click="$emit('getPageNo',pageNo+1)">
          <a>下一页»</a>
        </li>
      </ul>
      <div><span>共{{total}}条&nbsp;</span></div>
    </div>
  </div>
</template>

7.5 分页器类名的添加

            :class="{active:pageNo===page}"

7.6 滚动行为

开发某一个产品的详情页面?

1.静态组件

2.发请求

3.vuex

4.动态展示组件

当点击商品的图片的时候,跳转到详情页面,在路由跳转的时候需要带上产品的ID给详情页面

detail路由:

        {
            path:'/detail/:id',
            component:Detail,
            meta:{isShow:true}
        },

路由跳转:

<router-link :to="`/detail/${good.id}`">
    <img :src="good.defaultImg" />
</router-link>

滚动行为:

使用前端路由,当切换到新的路由时,想要页面滚到顶部,或者是保持原先的滚动位置,就像重新加载页面那样。vue-router能做到,且做得更好,他可以让你自定义路由切换时页面如何滚动

注意:这个功能只在支持history.pushState的浏览器中可用

export default new VueRouter({
    routes,
    scrollBehavior(to,from,savePosition){
        //返回的这个y=0,代表页面在最上面
        return {y:0}
    }
})

7.7 产品详情数据获取

获取数据的接口:

//获取产品详细信息的接口 URL:/api/item/{skuId} 请求方式:get
export const reqGoodsInfo=(skuId)=>requests({
    url:`/item/${skuId}`,
    method:'get'
})

vuex仓库捞数据:

import {reqGoodsInfo} from "@/api";

const state={
    goodsInfo:{}
}
const mutations={
    GETGOODSINFO(state,goodsInfo){
        state.goodsInfo=goodsInfo
    }
}
const actions={
    //获取产品信息的action
    async getGoodsInfo({commit},skuId){
        let result=await reqGoodsInfo(skuId)
        if(result.code===200){
            commit(GETGOODSINFO(result.data))
        }
    }
}
const getters={}

export default {
    state,mutations,actions,getters
}

7.8 产品详情展示动态数据

报错,但不影响程序运行

这是因为仓库中的getters方法:

        state.goodsInfo初始状态为空对象,空对象的categoryView属性值为undefined。undefined.category1Id肯定会报错。

const getters={
    categoryView(state){
        return state.goodsInfo.categoryView
    }
}

可以改为:

        那么此时计算出来的categoryView属性值至少是一个空对象,假的报错就不会有了

const getters={
    categoryView(state){
        return state.goodsInfo.categoryView||{}
    }
}

Detail组件和Zoom组件通信的时候,通用会报错,可以修改为:

      skuImageList(){
        return this.skuInfo.skuImageList||[{}]
      }

day8

8.1 产品售卖属性值排他操作

先通过遍历父数组将所有子元素的isChecked取消

再将点中的子元素isChecked点亮

      changeActive(spuSaleAttrValue,spuSaleAttrValueList){
          spuSaleAttrValueList.forEach(item=>{
            item.isChecked='0'
          })
        spuSaleAttrValue.isChecked='1'
      }

8.2 Zoom和ImgList兄弟组件间通信

通过ImgList传递给Zoom的index,使得轮播图中的图片改变导致Zoom中展示的大图一起改变(联动效应)

ImgList:

      changeCurrentIndex(index){
        this.currentIndex=index
        this.$bus.$emit('getIndex',this.currentIndex)
      }

Zoom:


    data(){
      return {
        currentIndex:0
      }
    },
    computed:{
      imgObj(){
        return this.skuImageList[this.currentIndex]||{}
      }
    },
    mounted(){
      this.$bus.$on('getIndex',(index)=>{
        this.currentIndex=index
      })
    }

8.3 轮播图实例的完成

注意没见过的新属性slidesPereView和slidesPerGroup

    watch:{
      //监听数据:虽然可以保证skuImageList数据已经传递过来,但v-for未必已经遍历完成
      skuImageList(newValue,oldValue){
        this.$nextTick(()=>{
          new Swiper(".swiper-container",{
            navigation:{
              nextEl:".swiper-button-next",
              prevEl:".swiper-button-prev"
            },
            //显示一次显示3个图片
            slidesPerView:3,
            //设置一次切换1张图片
            slidesPerGroup:1
          })
        })
      }
    }

8.4 放大镜的实现

遮罩层方法剖析:

big是用来显示mask滑过的区域,mask向右的时候,big对应的图片应该向左,大小为两倍差

      handler(event){
        let mask=this.$refs.mask
        let big=this.$refs.big
        let left=event.offsetX-mask.offsetWidth/2
        let top=event.offsetY-mask.offsetHeight/2
        //约束范围
        if(left<=0) left=0
        else if(left>=mask.offsetWidth) left=mask.offsetWidth
        if(top<=0) top=0
        else if(top>=mask.offsetHeight) top=mask.offsetHeight
        //修改元素的left|top
        mask.style.left=left+'px'
        mask.style.top=top+'px'
        big.style.left=-2*left+'px'
        big.style.top=-2*top+'px'
      }
    }

总代码:

<template>
  <div class="spec-preview">
    <img :src="imgObj.imgUrl" />
    <div class="event" @mousemove="handler"></div>
    <div class="big">
      <img :src="imgObj.imgUrl" ref="big"/>
    </div>
<!--    遮罩层-->
    <div class="mask" ref="mask"></div>
  </div>
</template>

<script>
  export default {
    name: "Zoom",
    props:['skuImageList'],
    data(){
      return {
        currentIndex:0
      }
    },
    computed:{
      imgObj(){
        return this.skuImageList[this.currentIndex]||{}
      }
    },
    mounted(){
      this.$bus.$on('getIndex',(index)=>{
        this.currentIndex=index
      })
    },
    methods:{
      handler(event){
        let mask=this.$refs.mask
        let big=this.$refs.big
        let left=event.offsetX-mask.offsetWidth/2
        let top=event.offsetY-mask.offsetHeight/2
        //约束范围
        if(left<=0) left=0
        else if(left>=mask.offsetWidth) left=mask.offsetWidth
        if(top<=0) top=0
        else if(top>=mask.offsetHeight) top=mask.offsetHeight
        //修改元素的left|top
        mask.style.left=left+'px'
        mask.style.top=top+'px'
        big.style.left=-2*left+'px'
        big.style.top=-2*top+'px'
      }
    }
  }
</script>

<style lang="less">
  .spec-preview {
    position: relative;
    width: 400px;
    height: 400px;
    border: 1px solid #ccc;

    img {
      width: 100%;
      height: 100%;
    }

    .event {
      width: 100%;
      height: 100%;
      position: absolute;
      top: 0;
      left: 0;
      z-index: 998;
    }

    .mask {
      width: 50%;
      height: 50%;
      background-color: rgba(0, 255, 0, 0.3);
      position: absolute;
      left: 0;
      top: 0;
      display: none;
    }

    .big {
      width: 100%;
      height: 100%;
      position: absolute;
      top: -1px;
      left: 100%;
      border: 1px solid #aaa;
      overflow: hidden;
      z-index: 998;
      display: none;
      background: white;

      img {
        width: 200%;
        max-width: 200%;
        height: 200%;
        position: absolute;
        left: 0;
        top: 0;
      }
    }

    .event:hover~.mask,
    .event:hover~.big {
      display: block;
    }
  }
</style>

8.5 购买产品个数的操作

      //表单元素修改产品个数
      changeSkuNum(event){
        //用户输入进来的文本*1
        let value=event.target.value*1
        //如果用户输入进来的非法
        if(isNaN(value)||value<1){
          this.skuNum=1
        }else{
          this.skuNum=parseInt(value)
        }
      }

8.6 加入购物车

接口api:

血的教训,千万不要把method写成methods!!!!

export const reqAddOrUpdateShopCart=(skuId,skuNum)=>requests({
    url:`/cart/addToCart/${skuId}/${skuNum}`,
    method:"post"
})

加入购物车成功的路由:

    {
        name:'addcartsuccess',
        path:'/addcartsuccess',
        component:AddCartSuccess,
        meta:{isShow:true}
    }

仓库中的action:

    //将产品添加到购物车中
    async addOrUpdateShopCart({commit},{skuId,skuNum}){
        //服务器写入数据成功以后,并没有返回其他的数据,只是返回了code=200,代表此处操作成功
        let result= await reqAddOrUpdateShopCart(skuId,skuNum)
        if(result.code==200){
            return 'ok'
        }else{
            return Promise.reject(new Error('fail'))
        }
    }

浏览器存储功能:

        本地存储:持久化的——5M

        会话存储:并非持久——会话结束就消失

路由传递参数结合会话存储:

进行路由跳转的时候需要将产品信息带给下一级的路由组件

一些简单的数据skuNum,通过query形式给路由组件传递过去

复杂的产品信息数据(比如skuInfo),通过会话存储即可(不持久化,会话结束数据消失)

      async addShopCart(){
        //1.发请求——将产品加入到数据库(通知服务器)
        try {
          await this.$store.dispatch('addOrUpdateShopCart', {
                skuId: this.$route.params.skuId,
                skuNum: this.skuNum})
          //2.服务器存储成功——进行路由跳转传递参数
          sessionStorage.setItem('SKUINFO',JSON.stringify(this.skuInfo))
            this.$router.push({
              name: 'addcartsuccess',
              query:{
                skuNum:this.skuNum
              }
            })
        }catch(err) {
          //3.失败——给用户进行提示
          alert(err.message)
        }
      }

AddCartSuccess组件中使用到sessionStorage存储的item:

    computed:{
      skuInfo(){
        return JSON.parse(sessionStorage.getItem('SKUINFO'))
      }
    }

day9

9.1 购物车静态组件与修改

购物车静态组件——需要修改样式结构

1.调整css让各个项目对齐

2.向服务器发送ajax,获取购物车数据

3.UUID临时游客身份

4.动态展示购物车

9.2 uuid游客身份获取购物车数据

获取购物车数据的接口:

//获取购物车列表数据接口
export const reqCartList=()=>requests({
    url:"/cart/cartList",
    method:"get"
})

向服务器发送ajax请求获取购物车数据:

        发请求的时候无法获取你购物车里面的数据,因为服务器不知道你是谁

        可以使用UUID临时游客身份

uuid函数逻辑:

随机生成一个字符串,且每次执行不能发生变化,游客身份持久存储

①从本地存储中获取uuid

②如果没有

        i.生成游客临时身份

        ii.本地存储

一定要有返回值!!!

import {v4 as uuidv4} from 'uuid'

export const getUUID=()=>{
    let uuid_token=localStorage.getItem("UUIDTOKEN")
    if(!uuid_token){
        uuid_token=uuidv4()
        localStorage.setItem('UUIDTOKEN',uuid_token)
    }
    return uuid_token
}

在请求拦截器中将存储好的uuid发往后台:

//请求拦截器
requests.interceptors.request.use((config)=>{
    if(store.state.detail.uuid_token){
        config.headers.userTempId=store.state.detail.uuid_token
    }

    //config是headers的请求头
    nprogress.start()
    return config
})

9.3 修改购物车产品的数量完成

            <a class="mins" @click="handler('minus',-1,cart)">-</a>
            <input
                autocomplete="off"
                type="text"
                :value="cart.skuNum"
                minnum="1"
                class="itxt"
                @change="handler('change',$event.target.value*1,cart)"
            >
            <a class="plus" @click="handler('add',1,cart)">+</a>
      //修改某一个产品的个数
      async handler(type,disNum,cart) {
        switch (type) {
            //加号
          case 'add':
            disNum = 1
            break
          case 'minus':
            disNum = cart.skuNum > 1 ? -1 : 0
            break
          case 'change':
            if(isNaN(disNum)||disNum<1){
              disNum=0
            }else{
              disNum=parseInt(disNum)-cart.skuNum
            }
        }
        try {
          await this.$store.dispatch('addOrUpdateShopCart', {
            skuId: cart.skuId,
            skuNum: disNum
          })
          this.getData()
        }catch(error){
          alert('修改失败')
        }
      }

特注:因为改变数量的时候如果点击过快可能会发生意想不到的结果,所以可以进行节流处理

      //修改某一个产品的个数
      handler:throttle(async function(type,disNum,cart){
        switch (type) {
            //加号
          case 'add':
            disNum = 1
            break
          case 'minus':
            disNum = cart.skuNum > 1 ? -1 : 0
            break
          case 'change':
            if(isNaN(disNum)||disNum<1){
              disNum=0
            }else{
              disNum=parseInt(disNum)-cart.skuNum
            }
        }
        try {
          await this.$store.dispatch('addOrUpdateShopCart', {
            skuId: cart.skuId,
            skuNum: disNum
          })
          this.getData()
        }catch(error){
          alert('修改失败')
        }
      })
      },

9.4 删除购物车产品的操作

接口:

//删除购物产品的接口
export const reqDeleteCartById=(skuId)=>requests({
    url:`/cart/deleteCart/${skuId}`,
    method:"delete"
})

具体的删除操作:

      //删除某一个产品的操作
      async deleteCartById(cart){
        try{
          await this.$store.dispatch('deleteCartListBySkuId',cart.skuId)
          //如果删除成功,再次发请求获取新的数据展示
          this.getData()
        }catch(error){
          alert(error.message)
        }
      }

9.5 修改产品状态

接口:

//修改商品的选中状态
export const reqUpdateCheckedById=(skuId,isChecked)=>requests({
    url:`/cart/checkCart/${skuId}/${isChecked}`,
    method:"get"
})

仓库:

    //修改购物车某一个产品的选中状态
    async updateCheckedById({commit},{skuId,isChecked}){
        let result =  await reqUpdateCheckedById(skuId,isChecked)
        if(result.code===200){
           return 'ok'
        }else{
            return Promise.reject(new Error('fail'))
        }
    }

修改template模板内容:

            <input
                type="checkbox"
                name="chk_list"
                :checked="cart.isChecked==1"
                @change="updateChecked(cart,$event)"
            >

修改script脚本内容:

      async updateChecked(cart,event){
        try{
          let checked=event.target.checked?"1":"0"
          await this.$store.dispatch('updateCheckedById',{skuId:cart.skuId,isChecked:checked})
          this.getData()
        }catch(error){
          
        }
      }

9.6 复习

1.加入购物车

UUID:点击加入购物车的时候,通过请求头给服务器带临时身份给服务器,存储某一个用户购物车数据

会话存储:去存储产品的信息以及展示功能

2.购物车功能

        修改产品的数量

        删除某一个产品的接口

        某一个产品的勾选状态切换

day10

10.1 删除全部选中的商品

context:小仓库,包含commit(提交mutations修改state)、getters(计算属性)、dispatch(派发action)、state(当前仓库数据)

    //删除全部勾选的产品
    deleteAllCheckedCart(context) {

    }

Promise.all([p1,p2,p3])

p1|p2|p3:每一个都是Promise对象,如果有一个Promise失败,都失败;如果都成功,返回成功

删除全部选中的商品

        action逻辑:

    //删除全部勾选的产品
    deleteAllCheckedCart({dispatch,getters}) {
        let promiseAll=[]
        //获取购物车中的全部产品
        getters.cartList.cartInfoList.forEach(item=>{
            let promise=item.isChecked===1?dispatch("deleteCartListBySkuId",item.skuId):''
            promiseAll.push(promise)
        })
        return Promise.all(promiseAll)
    }

        组件里的逻辑:


      async deleteAllCheckedCart(){
        try{
          await this.$store.dispatch("deleteAllCheckedCart")
          this.getData()
        }catch(error){
         alert(error.message) 
        }
      }

10.2 全部商品的勾选状态修改

actions里的逻辑:

    updateAllCartIsChecked({dispatch,state},isChecked){
        let promiseAll=[]
        state.cartList[0].cartInfoList.forEach(item=>{
            let promise=dispatch("updateCheckedById",{
                skuId:item.skuId,
                isChecked:isChecked
            })
            promiseAll.push(promise)
            return Promise.all(promiseAll)
        })
    }

组件里的逻辑:

      //修改全部产品选中的状态
      async updateAllCartChecked(event){
        try{
          let isChecked=event.target.checked?"1":"0"
          await this.$store.dispatch("updateAllCartIsChecked",isChecked)
          this.getData()
        }catch(error){
          alert(error.message)
        }
      }

组件的input全选框判断:

      <div class="select-all">
        <input class="chooseAll"
               type="checkbox"
               :checked="isAllChecked&&cartInfoList.length>0"
              @change="updateAllCartChecked($event)"
        >
        <span>全选</span>
      </div>

10.3 注册业务

assets文件夹打包以后,整个项目在dist目录下,assets文件夹会消失

        assets文件夹——放置全部组件共用的静态资源

在样式中也可以使用@符号:

        url中使用@代表src路径:

            background-image: url(~@/assets/images/icons.png);

接口api:

//获取验证码
export const reqGetCode=(phone)=>requests({
    url:`/user/passport/sendCode/${phone}`,
    method:"get"
})

仓库:

import {reqGetCode} from "@/api";

const state={
    code:''
}
const mutations={
    GETCODE(state,code){
        state.code=code
    }
}
const actions={
    //获取验证码
    async getCode({commit},phone){
        let result=await reqGetCode(phone)
        if(result.code===200){
            commit("GETCODE",result.data)
        }else{
            return Promise.reject(new Error('fail'))
        }
    }
}
const getters={}

export default {
    state,mutations,actions,getters
}

发送请求到仓库:

        <button style="width:100px;height:38px;" @click="$store.dispatch('getCode',phone)">获取验证码</button>

或:

      async getCode(){
        try{
         this.phone&&(await this.$store.dispatch("getCode",this.phone))
          console.log(this.$store.state.user.code)
        }catch(error){
          alert(error.message)
        }
      }

注册成功后跳转到登录页面:

接口api:

//注册
export const reqUserRegister=(data)=>requests({
    url:`/user/passport/register`,
    data,
    method:"post"
})

仓库里的actions逻辑:

    //用户注册
    async userRegister({commit},user){
        let result=await reqUserRegister(user)
        if(result.code===200){
            return 'ok'
        }else{
            return Promise.reject(new Error('fail'))
        }
    }

组件里的逻辑:

      async userRegister(){
        try{
          const {phone,code,password,rePassword}=this
          phone&&code&&password===rePassword&&(await this.$store.dispatch("userRegister",{phone,code,password}))
          this.$router.push("/login")
        }catch(error) {
          alert(error.message)
        }
      }

10.4 登录业务

登录业务:

注册:通过数据库存储用户信息(名字、密码)

登录:登录成功的时候,后台为了区分你这个用户是谁,会让服务器下发token(令牌:唯一标识符)

登陆接口:一般登陆成功后服务器会下发token,前台持久化存储token,然后前台带着token 去找服务器登录

接口api:

//登录
export const reqUserLogin=(data)=>requests({
    url:"/user/passport/login",
    data,
    method:"post"
})

组件逻辑:

      async userLogin(){
        try{
          const {phone,password}=this
          (phone&&password)&&(await this.$store.dispatch("userLogin",{phone,password}))
          this.$router.push("/home")
        }catch(error){
          alert(error.message)
        }
      }

仓库逻辑:

actions:

    //登录业务
    async userLogin({commit},data){
        let result=await reqUserLogin(data)
        if(result.code===200){
            commit("USERLOGIN",result.data.token)
            return 'ok'
        }else{
            return Promise.reject(new Error('fail'))
        }
    }

mutations:

    USERLOGIN(state,token){
        state.token=token
    }

前台携带token获取用户信息:

export const reqUserInfo=()=>requests.get("/user/passport/auth/getUserInfo")

处理请求拦截器:

//请求拦截器
requests.interceptors.request.use((config)=>{
    if(store.state.detail.uuid_token){
        config.headers.userTempId=store.state.detail.uuid_token
    }
    if(store.state.user.token){
        config.headers.token=store.state.user.token
    }

    //config是headers的请求头
    nprogress.start()
    return config
})

获取服务器返回的用户信息:

    async getUserInfo({commit}){
        let result=await reqUserInfo()
        if(result.code===200){
            commit("GETUSERINFO",result.data)
            return 'ok'
        }else{
            return Promise.reject(new Error('fail'))
        }
    }
    GETUSERINFO(state,userInfo){
        state.userInfo=userInfo
    }

处理Header组件:

        <div class="loginList">
          <p>尚品汇欢迎您!</p>
          <p v-if="!userName">
            <span>请</span>
            <router-link to="/login">登录</router-link>
            <router-link to="/register" class="register">免费注册</router-link>
          </p>
          <p v-else>
            <a>{{userName}}</a>
            <a class="register">退出登录</a>
          </p>
        </div>

vuex仓库存储的数据并不持久化,一刷新数据就没了。可以通过本地存储持久化token:

    //登录业务
    async userLogin({commit},data){
        let result=await reqUserLogin(data)
        if(result.code===200){
            commit("USERLOGIN",result.data.token)
            localStorage.setItem("TOKEN",result.data.token)
            return 'ok'
        }else{
            return Promise.reject(new Error('fail'))
        }
    }
const state={
    code:'',
    token:localStorage.getItem("TOKEN"),
    userInfo:{}
}

目前存在的bug:

①多个组件展示用户信息需要在每一个组件的mounted中触发this.$store.dispatch("getUserInfo")

②用户已经登陆,就不能再跳转到登录页

10.5 退出登录

退出登录需要做的事:

①需要发送请求,通知服务器退出登录(清除一些数据,比如token)

②清除项目当中的数据

接口api:

export const reqLogout=()=>requests.get("/user/passport/logout")

清除记录的数据:

    async userLogout({commit}){
        let result=await reqLogout()
        if(result.code===200){
            commit("CLEAR")
            return "ok"
        }else{
            return Promise.reject(new Error("fail"))
        }
    }
    CLEAR(state){
        state.token=""
        state.userInfo={}
        localStorage.clear()
    }

派发action:

    async logout(){
      try{
        await this.$store.dispatch("userLogout")
        this.$router.push("/home")
      }catch(error){
        alert(error.message)
      }
    }

10.6 导航守卫用户登录操作

导航:表示路由正在发生变化,进行路由跳转

导航守卫可以简单地分为全局守卫、路由独享守卫、组件内守卫

全局前置守卫:

        当一个导航触发时,全局前置守卫按照创建顺序调用。守卫是异步解析执行,此时导航在所有守卫resolve完之前一直处于等待中

参数剖析:

        to:可以获取到你要跳转到的那个路由的信息

        from:可以获取到你从哪个路由而来的信息

        next:放行函数

                next()放行       

                next("/login")放行到指定的路由中

                next(false)中断当前的导航。如果浏览器的URL改变了(可能是用户手动或者浏览器后退按钮),那么URL地址会重置到from路由对应的地址

router.beforeEach((to, from, next)=>{
    
})

全局路由守卫逻辑:

1.如果已经登录

        i.想去的是login路由,跳转到主页

        ii.想去的不是login路由

                ①如果有用户名,直接放行(不能用userInfo判断,因为空对象为真值)

                ②如果没有用户名,可能是刷新导致仓库的数据消失了,再去捞一下数据,然后放行

                        如果捞不到数据,就直接退出登录,回到登录界面

2.如果没有登陆

        直接放行(此时还没有做处理,后续可能会有追加)

router.beforeEach(async (to, from, next)=>{
    let token=store.state.user.token
    let name=store.state.user.userInfo.name
    if(token) {
        //登陆了订单还想去login或register(不能去,停留在首页)
        if (to.path === '/login'||to.path === '/register') {
            next("/home")
        } else {
            //登陆了,去的不是login
            //如果用户信息
            if (name) {
                next()
            } else {
                try {
                    //没有用户信息,派发action让仓库存储用户信息再跳转
                    await store.dispatch("getUserInfo")
                    next()
                } catch (error) {
                    //token失效,获取不到用户信息,需要重新登陆
                    //清除token
                    await store.dispatch("userLogout")
                    next("/login")
                }
            }
        }
    }else{
        next()
    }
})

10.7 获取交易页数据及展示

api接口:

//获取用户地址信息
export const reqAddressInfo=()=>requests({
    url:"/user/userAddress/auth/findUserAddressList",
    method:"GET"
})
//获取商品清单
export const reqOrderInfo=()=>requests({
    url:"/order/auth/trade",
    method:"GET"
})

仓库:

import {reqAddressInfo, reqOrderInfo} from "@/api";

const state={
    address:[],
    orderInfo:{}
}
const mutations={
    GETUSERADDRESS(state,address){
        state.address=address
    },
    GETORDERINFO(state,orderInfo){
        state.orderInfo=orderInfo
    }
}
const actions={
    async getUserAddress({commit}){
       let result = await reqAddressInfo()
        if(result.code===200){
            commit("GETUSERADDRESS",result.data)
        }
    },
    async getOrderInfo({commit}){
        let result = await reqOrderInfo()
        if(result.code===200){
            commit("GETORDERINFO",result.data)
        }
    }
}
const getters={}

export default {
    state,mutations,actions,getters
}

组件逻辑:

注意mapState和mapGetters的区别

<script>
import {mapState} from "vuex";

export default {
    name: 'Trade',
    data(){
        return {
          msg:""
        }
    },
    mounted(){
      this.$store.dispatch("getUserAddress"),
      this.$store.dispatch("getOrderInfo")
    },
    computed:{
      ...mapState({
        addressInfo:state=>state.trade.address,
        orderInfo:state=>state.trade.orderInfo
      }),
      //将来提交订单最终选中地址
      userDefaultAddress(){
        return this.addressInfo.find(item=>item.isDefault==1)||{}
      },
      detailArrayList(){
        return this.orderInfo.detailArrayList||[]
      }
    },
    methods:{
      changeDefault(address,addressInfo){
        //find:查找数组当中符合条件的元素作为返回值返回
        addressInfo.forEach(item=>item.isDefault=0)
        address.isDefault=1
      }
    }
  }
</script>

day11

11.1 提交订单

①静态组件

②点击提交订单的按钮时,还需要向服务器发送一次请求,把一些支付的相关信息传递给服务器

接口:

export const reqSubmitOrder=(tradeNo,data)=>requests({
    url:`/order/auth/submitOrder?tradeNo=${tradeNo}`,
    data,
    method:"post"
})

统一接口api文件夹里面全部的请求函数:

import * as API from "@/api"


new Vue({
  render: h => h(App),
  beforeCreate(){
    Vue.prototype.$bus=this
    Vue.prototype.$API=API
  },
  router,
  store,
}).$mount('#app')

发送请求到支付页面:

      async submitOrder(){
        let {tradeNo}=this;
        let data={
          consignee:this.userDefaultAddress.consignee,
          consigneeTel:this.userDefaultAddress.phoneNum,
          deliveryAddress:this.userDefaultAddress.fullAddress,
          paymentWay:"ONLINE",
          orderComment:this.msg,
          orderDetailList:this.detailArrayList
        }
        let result=await this.$API.reqSubmitOrder(tradeNo,data)
        console.log(result)
        if(result.code===200){
          this.orderId=result.data
          this.$router.push(`/pay?orderId=${this.orderId}`)
        }else{
          alert(result.data)
        }
      }

11.2 获取订单号与展示支付信息

接口:

//获取支付的信息
export const reqPayInfo=(orderId)=>requests({
    url:`/payment/weixin/createNative/${orderId}`,
    method:"get"
})

生命周期函数中不能使用async

<script>
  export default {
    name: 'Pay',
    data(){
      return {
        payInfo:{}
      }
    },
    computed:{
      orderId(){
        return this.$route.query.orderId
      }
    },
    mounted(){
      this.getPayInfo()
    },
    methods:{
      async getPayInfo(){
        let result=await this.$API.reqPayInfo(this.orderId)
        if(result.code===200){
          this.payInfo=result.data
        }
      }
    }
  }
</script>

11.3 支付页面中使用ElementUI以及按需引入

//注册全局组件
import {Button,MessageBox} from 'element-ui'
//ElementUI注册组件的时候还有一种写法:挂在原型上
Vue.use(Button)
Vue.prototype.$msgbox = MessageBox;
Vue.prototype.$alert = MessageBox.alert;

ElementUI按需引入:

借助 babel-plugin-component,我们可以只引入需要的组件,以达到减小项目体积的目的

①安装 babel-plugin-component

yarn add babel-plugin-component -D

②将babel.config.js修改为:

module.exports = {
  presets: [
    '@vue/cli-plugin-babel/preset',
  ],
  "plugins":[
      [
          "component",
        {
          libraryName:'element-ui',
          "StyleLibraryName":'theme-chalk'
        }
      ]
  ]

}

直接使用:

          <el-button class="btn" @click="open">立即支付</el-button>
      //弹出框
      open(){
        this.$alert(`<strong>这是 <i>HTML</i> 片段</strong>`,'HTML片段',{
          dangerouslyUseHTMLString:true,
          //中间布局
          center:true,
          //是否显示取消按钮
          showCancelButton:true,
          //取消按钮的文本内容
          cancelButtonText:"支付遇见问题",
          //确定按钮的文本内容
          confirmButtonText:"已经支付成功",
          //右上角的叉叉是否显示
          showClose:false
        })
      }

11.4 微信支付业务

使用qrcode,一个用于生成二维码的JavaScript库

获取支付订单状态的接口:

//获取支付订单状态
export const reqPayStatus=(orderId)=>requests({
    url:`/payment/weixin/queryPayStatus/${orderId}`,
    method:"get"
})

弹出框业务:

      //弹出框
      async open(){
        //生成二维码地址
        let url=await QRCode.toDataURL(this.payInfo.codeUrl)
        this.$alert(`<img src="${url}"/>`,'HTML片段',{
          dangerouslyUseHTMLString:true,
          //中间布局
          center:true,
          //是否显示取消按钮
          showCancelButton:true,
          //取消按钮的文本内容
          cancelButtonText:"支付遇见问题",
          //确定按钮的文本内容
          confirmButtonText:"已经支付成功",
          //右上角的叉叉是否显示
          showClose:false,
          //关闭弹出框的配置值
          beforeClose:(type,instance,done)=>{
            //type:区分取消|确定按钮
            //instance:当前组件实例
            //done:关闭弹出框的方法
            if(type==='cancel'){
              alert('请联系管理员')
              clearInterval(this.timer)
              this.timer=null
              //关闭弹出框
              done()
            }else{
              //判断是否真的支付了
              if(this.code===200){
                clearInterval(this.timer)
                this.timer=null
                done()
                this.$router.push('/paysuccess')
              }
            }
          }
        })
        if(!this.timer){
          this.timer=setInterval(async ()=>{
            //发请求获取用户支付状态
            let result=await this.$API.reqPayStatus(this.orderId)
            //如果code===200
            if(result.code===200){
              //第一步:清除定时器
               clearInterval(this.timer)
              this.timer=null
              //保存支付成功返回的code
               this.code=result.code
              //关闭弹出窗
              this.$msgbox.close()
              //跳转到下一路由
              this.$router.push('/paysuccess')
            }
          },1000)
        }
      }

day12

12.1 个人中心二级路由的搭建

如果只进入/center,可能会出现右侧没有内容的情况,所以需要重定向到/center/myorder

    {
        path:'/center',
        component:Center,
        meta:{isShow:true},
        children:[
            {
                path:'myorder',
                component:MyOrder
            },
            {
                path:'grouporder',
                component:GroupOrder
            },
            {
                path:'/center',
                redirect:"/center/myorder"
            }
        ]
    }

12.2 我的订单

接口api:

//获取个人中心的数据
export const reqMyOrderList=(page,limit)=>requests({
    url:`/order/auth/${page}/${limit}`,
    method:"get"
})

12.3 未登录的导航守卫判断

全局前置守卫逻辑处理:

        //未登录:不能去交易相关、支付相关的页面(pay|paysuccess)、不能去个人中心
        //未登录若去上述页面---跳转至登录页面
        let toPath=to.path
        if(toPath.indexOf('/trade')!==-1||toPath.indexOf('/pay')!==-1||toPath.indexOf('/center')!==-1){
            next('/login?redirect='+toPath)
        }else {
         //去的不是上面这些路由(home|search|shopCart)---放行
            next()
        }

对login的处理:增加了对query参数的判断

      async userLogin(){
        try{
          const {phone,password}=this;
          (phone&&password)&&(await this.$store.dispatch("userLogin",{phone,password}))
          let toPath=this.$route.query.redirect||'/home'
          this.$router.push(toPath)
        }catch(error){
          alert(error.message)
        }
      }

12.4 用户登录(路由独享与组件内守卫)

路由独享守卫:

只有从购物车界面才能跳转到交易页面(创建订单)

    {
        path:"/trade",
        component: Trade,
        meta:{isShow:true},
        //路由独享守卫
        beforeEnter:(to,from,next)=>{
            if(from.path==='/shopcart'){
                next()
            }else{
                next(false)
            }
        }
    }

只有从交易页面(创建订单)页面才能跳转到支付页面

    {
        path:"/pay",
        component:Pay,
        meta:{isShow:true},
        beforeEnter:(to,from,next)=>{
            if(from.path==='/trade'){
                next()
            }else{
                next(false)
            }
        }
    }

只有从支付页面才能跳转到支付成功页面

组件内守卫:

beforeRouterEnter(to,from,next){        }:

        在渲染该组件的对应路由被confirm前调用

        不能获取组件的实例“this”

        因为当守卫执行前,组件实例还没有被创建

  export default {
    name: 'PaySuccess',
    beforeRouteEnter(to,next,from){
      if(from.path==='/pay'){
        next()
      }else{
        next(false)
      }
    }
  }

beforeRouteUpdate(to,from,next){        }:

        在当前路由改变、但是该组件被复用时调用

        举例来说,对于一个带有动态参数的路径/foo/:id,在/foo/1和/foo/2之间跳转的时候

        由于会渲染同样的Foo组件,因此组件实例会被复用,而这个钩子就是这种情况下被调用

        可以访问组件实例“this”

beforeRouteLeave(to,from,next){        }:

        导航离开该组件的对应路由时调用

        可以访问组件实例的“this”

12.5 图片懒加载

①下载插件vue-lazyload

注意vue2对应1.3.3版本的vue-lazyload,否则会报路径错误

yarn add vue-lazyload@1.3.3

②图片、json不需要对外暴露就可引入

import VueLazyLoad from 'vue-lazyload'
import winter from '@/assets/winter.jpg'
Vue.use(VueLazyLoad,{
  //懒加载默认图片
  loading:winter
})

在需要默认图片的模板中插入:

                      <img v-lazy="good.defaultImg" />

12.6 vee-validate表单验证的使用

①安装:注意vue2对应vee-validate2版本

yarn add vee-validate@2   

②在main.js中引入表单校验插件

//引入表单校验插件
import '@/plugins/validate'

③编写表单校验插件内容

import Vue from 'vue'
import VeeValidate from 'vee-validate'
import zh_CN from 'vee-validate/dist/locale/zh_CN'
Vue.use(VeeValidate)

//表单验证
VeeValidate.Validator.localize("zh_CN",{
    messages:{
        ...zh_CN.messages,
        is:(field)=>`${field}必须与密码相同`,
    },
    attributes:{
        phone:"手机号",
        code:"验证码",
        password:"密码",
        rePassword:"确认密码",
        agree:"协议"
    }
})

④改写模板里的代码(以手机号校验为例)

      <div class="content">
        <label>手机号:</label>
        <input type="text"
               placeholder="请输入你的手机号"
               v-model="phone"
                name="phone" v-validate="{required:true,regex:/^1\d{10}$/}" :class="{invalid:errors.has('phone')}"
        >
        <span class="error-msg">{{errors.first("phone")}}</span>
      </div>

⑤效果图

不输入时:

 输入错误时:

 

格式正确时警告会消失:

 

验证码校验:

      <div class="content">
        <label>验证码:</label>
        <input type="text"
               placeholder="请输入验证码"
               v-model="code"
              name="code" v-validate="{required:true,regex:/^\d{6}$/}" :class="{invalid:errors.has('code')}"
        >
        <button style="width:100px;height:38px;" @click="getCode">获取验证码</button>
        <span class="error-msg">{{errors.first("code")}}</span>
      </div>

密码校验:

      <div class="content">
        <label>登录密码:</label>
        <input type="password"
               placeholder="请输入你的登录密码"
               v-model="password"
               name="password" v-validate="{required:true,regex:/^[0-9a-zA-Z]{8,20}$/}" :class="{invalid:errors.has('password')}"

        >
        <span class="error-msg">{{errors.first("password")}}</span>
      </div>

确认密码校验:

      <div class="content">
        <label>确认密码:</label>
        <input type="password"
               placeholder="请输入确认密码"
               v-model="rePassword"
               name="rePassword" v-validate="{required:true,is:password}" :class="{invalid:errors.has('rePassword')}"

        >
        <span class="error-msg">{{errors.first("rePassword")}}</span>
      </div>

效果图:

 

 

对于勾选同意协议的复选框,必须自定义规则

//自定义校验规则
VeeValidate.Validator.extend("agree",{
    validate:value=>{
        return value
    },
    getMessage:field=>field+"必须同意"
})

修改模板里的内容:

      <div class="controls">
        <input
            type="checkbox"
            :checked="agree"
            name="agree" v-validate="{required:true,'agree':true}" :class="{invalid:errors.has('agree')}"
        >
        <span>同意协议并注册《尚品汇用户协议》</span>
        <span class="error-msg">{{errors.first("agree")}}</span>
      </div>

如果所有的表单验证都通过,再向服务器发送请求进行注册

      async userRegister(){
        const success=await this.$validator.validateAll()
        if(success){
          try{
            const {phone,code,password,rePassword}=this
            await this.$store.dispatch("userRegister",{phone,code,password})
            this.$router.push("/login")
          }catch(error) {
            alert(error.message)
          }
        }
      }

12.7 路由懒加载

当打包构建应用时,JavaScript包会变的非常大,影响页面加载。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,就会更加高效

以Search组件为例:

    {
        name:'search',
        path:"/search/:keyword?",
        component:()=>import("@/pages/Search.vue"),
        meta:{show:true}
    }

12.8 处理map文件

打包:yarn build

项目打包后,代码都是经过压缩加密的,如果运行时报错,输出的错误信息无法准确得知是哪里的代码报错。有了map就可以像未加密的代码一样,准确的输出是哪一行哪一列又错了

文件如果项目不需要可以去掉

Vue.config.js配置中productionSourceMap:false可以不生成体积很大的map文件

  productionSourceMap:false

12.9 购买服务器

可以去阿里云或者腾讯云购买服务器,腾讯云更便宜


完结撒花❀❀~

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值