移动端项目

O 项目技术栈说明

  • 脚手架: Vite 3

还有 vue-cli - 底层 webpack

  • 脚本:typescript
  • 路由:vue-router4
  • 状态管理器: vuex4

还有 pinia

  • 组件库:vant@3.6.3
  • 组件API:选项式API

一、Vite 脚手架的使用

官网:https://vitejs.cn/

vue3 Vite官网:https://cn.vitejs.dev/

Vite 下一代的前端工具链 为开发提供极速响应

$ cnpm i vite -g

1.1 介绍

Vite(法语意为 “快速的”,发音 /vit/,发音同 “veet”)是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:

Vite 意在提供开箱即用的配置,同时它的 插件 APIJavaScript API 带来了高度的可扩展性,并有完整的类型支持。

1.2 搭建vue3项目

1.2.1 vite官方建议

使用 NPM:

$ npm create vite@latest

使用 Yarn: 如果电脑尚未安装yarn,可通过cnpm i yarn -g完成安装

$ yarn create vite

使用 PNPM: 如果电脑尚未安装pnpm,可通过cnpm i pnpm -g完成安装

$ pnpm create vite

然后按照提示操作即可!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Prq95Hvt-1673602369087)(assets/image-20220923090140128.png)]

1.2.2 vue官网生态系统建议

vue脚手架

要使用 Vite 来创建一个 Vue 项目,非常简单

$ npm init vue@latest

这个命令会安装和执行 create-vue,它是 Vue 提供的官方脚手架工具。跟随命令行的提示继续操作即可。


√ Project name: ... mobile-vue-app # 输入项目名称
√ Add TypeScript? ... No / Yes # Yes 选择使用ts
√ Add JSX Support? ... No / Yes # Yes 选择使用jsx支持
√ Add Vue Router for Single Page Application development? ... No / Yes # Yes 选择使用vue路由
√ Add Pinia for state management? ... No / Yes # No 后期使用vuex作为状态管理器
√ Add Vitest for Unit Testing? ... No / Yes # No 不选择测试
√ Add Cypress for both Unit and End-to-End testing? ... No / Yes # No 不选择
√ Add ESLint for code quality? ... No / Yes # Yes 选择代码格式校验
√ Add Prettier for code formatting? ... No / Yes # Yes 选择Prettier作为代码格式规范
$ cd mobile-vue-app # 进入项目目录
$ npm i # 安装依赖 如果安装出错,建议可以考虑使用手机热点安装
$ npm run lint # 代码格式校验
$ npm run dev # 启动项目

如果同局域网内想要通过ip地址访问项目,可以修改 运行命令

// package.json
{
	...,
    "scripts": {
    	"dev": "vite"// -----
    	"dev": "vite --host" // +++++
    	...,
	},
	....
}

1.3 目录结构分析

|- mobile-vue-app  # 项目名称
  |- .vscode
  |- node_modules # 项目依赖
  |- public		  # 图标文件夹
	favicon.ico   # 网页图标
  |- src		  # 写代码主场
	|- assets	  # 资源文件
	   base.css   # 基础样式
	   logo.svg   # logo
	   main.css   # 项目样式
	|- components # 自定义组件
		|- icons  # 图标组件
	   HelloWorld.vue # 自定义组件
	   TheWelcome.vue # 自定义组件
	   WelcomeItem.vue # 自定义组件
	|- router # 路由文件夹
	   index.ts # 路由的配置
	|- views # 项目页面组件
	   AboutView.vue # 页面组件
	   HomeView.vue # 页面组件
	  App.vue # 项目根组件
	  main.ts # 项目入口文件
     .eslintrc.cjs # 代码格式化规则
	 .gitignore # git上传忽略文件
	 .prettierrc.json # 右键格式化的时候,就会自动帮我们补全符号
	 .env.d.ts # 环境配置声明文件
	 index.html # 页面的模板
	 package.json # 项目依赖说明
	 README.md	# 说明文档 
	 tsconfig.config.json # ts配置文件说明
	 tsconfig.json # ts配置文件
	 vite.config.ts # vite配置文件

二、Vue3单文件组件

1.SFC语法定义

一个 Vue 单文件组件 (SFC),通常使用 *.vue 作为文件扩展名,它是一种使用了类似 HTML 语法的自定义文件格式,用于定义 Vue 组件。一个 Vue 单文件组件在语法上是兼容 HTML 的。

每一个 *.vue 文件都由三种顶层语言块构成:<template><script><style>

<template>
  <div class="example">{{ msg }}</div>
</template>

<script>
export default {
  data() {
    return {
      msg: 'Hello world!'
    }
  }
}
</script>

<style>
.example {
  color: red;
}
</style>

打开 main.ts 发现 引入App.vue 的地方画红线,说明项目中没有说明 .vue文件的声明文件

// src/env.d.ts
/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

再次打开main.ts 发现,红线消失

复制项目src文件夹,然后保留App.vue以及main.ts的基本内容

// src/main.ts
import { createApp } from 'vue'

import App from './App.vue'

const app = createApp(App)

app.mount('#app')

选项式API

<!-- src/App.vue 选项式API-->
<script lang="ts">
  import { defineComponent } from 'vue'
  // 如果是js作为脚本语言  只需要 export default {}
  export default defineComponent({
    data () {
      return {
        msg: 'hello sfc',
        count: 10
      }
    }
  })
</script>

<template>
  <div class="example">
    {{ msg }} --- {{ count }}
    <button @click="count++">加1</button>
  </div>
</template>
<!-- 如果样式只针对当前组件是有效的,只需要给style添加 scoped 属性即可 -->
<style scoped>
  .example {
    color: #f66;
  }
</style>

vue 组合式API

<!-- src/App.vue 组合式API-->
<script lang="ts">
  import { defineComponent, ref } from 'vue'
  // 如果是js作为脚本语言  只需要 export default {}
  export default defineComponent({
    // data () {
    //   return {
    //     msg: 'hello sfc',
    //     count: 10
    //   }
    // }
    setup () {
      const msg = 'hello sfc'
      const count = ref(10)

      return {
        msg,
        count
      }
    }
  })
</script>

<template>
  <div class="example">
    {{ msg }} --- {{ count }}
    <button @click="count++">加1</button>
  </div>
</template>
<!-- 如果样式只针对当前组件是有效的,只需要给style添加 scoped 属性即可 -->
<style scoped>
  .example {
    color: #f66;
  }
</style>

组合式API简写形式

<!-- src/App.vue 组合式API简写形式-->
<script lang="ts" setup>
  import { ref } from 'vue'
  const msg = 'hello sfc'
  const count = ref(10)
</script>

<template>
  <div class="example">
    {{ msg }} --- {{ count }}
    <button @click="count++">加1</button>
  </div>
</template>
<!-- 如果样式只针对当前组件是有效的,只需要给style添加 scoped 属性即可 -->
<style scoped>
  .example {
    color: #f66;
  }
</style>

说明:如果使用 js 作为脚本语言,是不需要引入 import { defineComponent } from 'vue'的,组件中只需要 通过export defaut {}作为组件的js逻辑即可

2.相应语言块(template、script、style)

2.1 template
  • 每个 *.vue 文件最多可以包含一个顶层 <template> 块。
  • 语块包裹的内容将会被提取、传递给 @vue/compiler-dom,预编译为 JavaScript 渲染函数,并附在导出的组件上作为其 render 选项。
2.2 script
  • 每个 *.vue 文件最多可以包含一个 <script> 块。(使用 <script setup> 的情况除外)
  • 这个脚本代码块将作为 ES 模块执行。
  • 默认导出应该是 Vue 的组件选项对象,可以是一个对象字面量或是 defineComponent 函数的返回值。
2.3 style
  • 每个 *.vue 文件可以包含多个 <style> 标签。
  • 一个 <style> 标签可以使用 scopedmodule attribute 来帮助封装当前组件的样式。使用了不同封装模式的多个 <style> 标签可以被混合入同一个组件。

3.预处理器

代码块可以使用 lang 这个 attribute 来声明预处理器语言,最常见的用例就是在 <script> 中使用 TypeScript:

<script lang="ts">
  // use TypeScript
</script>

lang 在任意块上都能使用,比如我们可以在 <style> 标签中使用 SASS 或是 <template> 中使用 Pug

<template lang="pug">
p {{ msg }}
</template>

<style lang="scss">
  $primary-color: #333;
  body {
    color: $primary-color;
  }
</style>

Vite 提供了对 .scss, .sass, .less, .styl.stylus 文件的内置支持。没有必要为它们安装特定的 Vite 插件,但必须安装相应的预处理器依赖:

# .scss and .sass
$ npm i -D sass

# .less
$ npm i -D less

# .styl and .stylus
$ npm i -D stylus

4. src导入

如果你更喜欢将 *.vue 组件分散到多个文件中,可以为一个语块使用 src 这个 attribute 来导入一个外部文件:

<template src="./template.html"></template>
<style src="./style.css"></style>
<script src="./script.js"></script>

请注意 src 导入和 JS 模块导入遵循相同的路径解析规则,这意味着:

  • 相对路径需要以 ./ 开头

5. 注释

在每一个语块中你都可以按照相应语言 (HTML、CSS、JavaScript 和 Pug 等等) 的语法书写注释。对于顶层注释,请使用 HTML 的注释语法 <!-- comment contents here -->

四、TS与选项式API

1.为组件的prop标注类型

选项式 API 中对 props 的类型推导需要用 defineComponent() 来包装组件。有了它,Vue 才可以通过 props 以及一些额外的选项,比如 required: truedefault 来推导出 props 的类型:

import { defineComponent } from 'vue'

export default defineComponent({
  // 启用了类型推导
  props: {
    name: String,
    id: [Number, String],
    msg: { type: String, required: true },
    metadata: null
  },
  mounted() {
    this.name // 类型:string | undefined
    this.id // 类型:number | string | undefined
    this.msg // 类型:string
    this.metadata // 类型:any
  }
})

然而,这种运行时 props 选项仅支持使用构造函数来作为一个 prop 的类型——没有办法指定多层级对象或函数签名之类的复杂类型。

我们可以使用 PropType 这个工具类型来标记更复杂的 props 类型

import { defineComponent } from 'vue'
import type { PropType } from 'vue'

interface Book {
  title: string
  author: string
  year: number
}

export default defineComponent({
  props: {
    book: {
      // 提供相对 `Object` 更确定的类型
      type: Object as PropType<Book>,
      required: true
    },
    // 也可以标记函数
    callback: Function as PropType<(id: number) => void>
  },
  mounted() {
    this.book.title // string
    this.book.year // number

    // TS Error: argument of type 'string' is not
    // assignable to parameter of type 'number'
    this.callback?('123')
  }
})

案例如下:

<!-- src/App.vue 选项式API-->
<script lang="ts" >
  import { defineComponent, ref } from 'vue'
  import Child from './Child.vue'

  export default defineComponent({
    components: {
      Child
    },
    data () {
      return {
        user: {
          userName: '张三',
          age: 18,
          sex: '男'
        }
      }
    }
  })
 
</script>

<template>
  <div class="example">
    <Child :user="user"></Child>
    <Child :user="{ userName: '李四', age: 28, sex: '女'}"></Child>
    <!-- Missing required prop: "user"  -->
    <Child />
  </div>
</template>
<style scoped lang="css">
</style>
<!-- src/Child.vue -->
<script lang="ts">
  import { defineComponent } from 'vue'
  import type { PropType } from 'vue'

  interface IUser {
    userName: string
    age: number
    sex: string
  }

  export default defineComponent({
    props: {
      // user: Object as PropType<IUser> // 类型断言
      user: {
        type: Object as PropType<IUser>,
        required: true,
        default: (): IUser => {
          return {
            userName: '王五',
            age: 10,
            sex: '男'
          }
        }
      }
    }
  })
</script>
<template>
  <div>子组件 - {{ user.userName }} - {{ user.age }} - {{ user.sex }}</div>
</template>

2.为组件的emit 标注类型

我们可以给 emits 选项提供一个对象来声明组件所触发的事件,以及这些事件所期望的参数类型。试图触发未声明的事件会抛出一个类型错误:

import { defineComponent } from 'vue'

export default defineComponent({
  emits: {
    addBook(payload: { bookName: string }) {
      // 执行运行时校验
      return payload.bookName.length > 0
    }
  },
  methods: {
    onSubmit() {
      this.$emit('addBook', {
        bookName: 123 // 类型错误
      })

      this.$emit('non-declared-event') // 类型错误
    }
  }
})

案例如下:

<!-- src/App.vue 选项式API-->
<script lang="ts" >
  import { defineComponent, ref } from 'vue'
  import Child from './Child.vue'

  export default defineComponent({
    components: {
      Child
    },
    data () {
      return {
        user: {
          userName: '张三',
          age: 18,
          sex: '男'
        }
      }
    },
    methods: {
      getData (val: { money: number }) {
        console.log(val)
      }
    }
  })
 
</script>

<template>
  <div class="example">
    <Child :user="user" @my-event="getData"></Child>
  </div>
</template>
<style scoped lang="css">
</style>
<!-- src/Child.vue -->
<script lang="ts">
  import { defineComponent } from 'vue'
  import type { PropType } from 'vue'

  interface IUser {
    userName: string
    age: number
    sex: string
  }

  export default defineComponent({
    props: {
      // user: Object as PropType<IUser> // 类型断言
      user: {
        type: Object as PropType<IUser>,
        required: true,
        default: (): IUser => {
          return {
            userName: '王五',
            age: 10,
            sex: '男'
          }
        }
      }
    },
    emits: {
      'my-event': (payload: { money: number }): boolean => {
        return payload.money > 100001
      }
    },
    methods: {
      sendData () {
        this.$emit('my-event', {
          money: 10000 //  Invalid event arguments: event validation failed for event "my-event".
        })
      }
    }
  })
</script>
<template>
  <div>
    子组件 - {{ user.userName }} - {{ user.age }} - {{ user.sex }}
    <button @click="sendData">子组件给父组件传值</button>
  </div>
</template>

3.为计算属性标注类型

计算属性会自动根据其返回值来推导其类型:

mport { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      message: 'Hello!'
    }
  },
  computed: {
    greeting() {
      return this.message + '!'
    }
  },
  mounted() {
    this.greeting // 类型:string
  }
})

在某些场景中,你可能想要显式地标记出计算属性的类型以确保其实现是正确的:

import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      message: 'Hello!'
    }
  },
  computed: {
    // 显式标注返回类型
    greeting(): string {
      return this.message + '!'
    },

    // 标注一个可写的计算属性
    greetingUppercased: {
      get(): string {
        return this.greeting.toUpperCase()
      },
      set(newValue: string) {
        this.message = newValue.toUpperCase()
      }
    }
  }
})

在某些 TypeScript 因循环引用而无法推导类型的情况下,可能必须进行显式的类型标注。

案例如下:

<!-- src/App.vue 选项式API-->
<script lang="ts" >
  import { defineComponent, ref } from 'vue'
  export default defineComponent({
    
    data () {
      return {
        firstName: '',
        lastName: ''
      }
    },
    computed: {
      // fullName (): string {
      //   return this.firstName + this.lastName
      // }
      fullName: {
        get (): string { return this.firstName + this.lastName },
        set (val: string): void { 
          this.firstName = val.split(' ')[0]
          this.lastName = val.split(' ')[1]
        }
      }
    },
    methods: {
      changeName () {
        this.fullName = '张 三丰'
      }
    }
  })
 
</script>

<template>
  <div class="example">
    <input type="text" v-model="firstName" /> + 
    <input type="text" v-model="lastName" /> = 
    {{ fullName }}
    <button @click="changeName">设置fullName</button>
  </div>
</template>
<style scoped lang="css">
</style>

4.为事件处理器标注类型

在处理原生 DOM 事件时,应该为我们传递给事件处理函数的参数正确地标注类型。

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  methods: {
    handleChange(event) {
      // `event` 隐式地标注为 `any` 类型
      console.log(event.target.value)
    }
  }
})
</script>

<template>
  <input type="text" @change="handleChange" />
</template>

没有类型标注时,这个 event 参数会隐式地标注为 any 类型。这也会在 tsconfig.json 中配置了 "strict": true"noImplicitAny": true 时抛出一个 TS 错误。因此,建议显式地为事件处理函数的参数标注类型。此外,你可能需要显式地强制转换 event 上的属性:

import { defineComponent } from 'vue'

export default defineComponent({
  methods: {
    handleChange(event: Event) {
      console.log((event.target as HTMLInputElement).value)
    }
  }
})

案例如下:

<!-- src/App.vue 选项式API-->
<script lang="ts" >
  import { defineComponent } from 'vue'
  export default defineComponent({
    methods: {
      handleChange (event: Event) {
        console.log((event.target as HTMLInputElement).value)
      }
    }
  })
 
</script>

<template>
  <div >
    <input type="text" @change="handleChange" />
  </div>
</template>
<style scoped lang="css">
</style>

5.扩充全局property

某些插件会通过 app.config.globalProperties 为所有组件都安装全局可用的属性。举例来说,我们可能为了请求数据而安装了 this.$http,或者为了国际化而安装了 this.$translate。为了使 TypeScript 更好地支持这个行为,Vue 暴露了一个被设计为可以通过 TypeScript 模块扩展来扩展的 ComponentCustomProperties 接口:

import axios from 'axios'

declare module 'vue' {
  interface ComponentCustomProperties {
    $http: typeof axios
    $translate: (key: string) => string
  }
}

我们可以将这些类型扩展放在一个 .ts 文件,或是一个影响整个项目的 *.d.ts 文件中。无论哪一种,都应确保在 tsconfig.json 中包括了此文件。对于库或插件作者,这个文件应该在 package.jsontypes 属性中被列出。

为了利用模块扩展的优势,你需要确保将扩展的模块放在 TypeScript 模块 中。 也就是说,该文件需要包含至少一个顶级的 importexport,即使它只是 export {}。如果扩展被放在模块之外,它将覆盖原始类型,而不是扩展!

// 正常工作。
export {}

declare module 'vue' {
  interface ComponentCustomProperties {
    $translate: (key: string) => string
  }
}

案例如下:

// src/main.ts
import { createApp } from 'vue'
// import axios from 'axios'

import App from './App.vue'

const app = createApp(App)

app.config.globalProperties.test = '测试'

app.mount('#app')
<!-- src/App.vue 选项式API-->
<script lang="ts" >
  import { defineComponent } from 'vue'
  export default defineComponent({
    mounted () {
      console.log(this.test) // 测试   报警告信息
    },
    methods: {
      handleChange (event: Event) {
        console.log((event.target as HTMLInputElement).value)
      }
    }
  })
 
</script>

<template>
  <div >
    <input type="text" @change="handleChange" />
  </div>
</template>
<style scoped lang="css">
</style>

打印 this.test 时遇到 ts 的问题

6.扩充自定义选项

某些插件,比如 vue-router,提供了一些自定义的组件选项,比如 beforeRouteEnter

import { defineComponent } from 'vue'

export default defineComponent({
  beforeRouteEnter(to, from, next) {
    // ...
  }
})

如果没有确切的类型标注,这个钩子函数的参数会隐式地标注为 any 类型。我们可以为 ComponentCustomOptions 接口扩展自定义的选项来支持:

import { Route } from 'vue-router'

declare module 'vue' {
  interface ComponentCustomOptions {
    beforeRouteEnter?(to: Route, from: Route, next: () => void): void
  }
}

现在这个 beforeRouteEnter 选项会被准确地标注类型。注意这只是一个例子——像 vue-router 这种类型完备的库应该在它们自己的类型定义中自动执行这些扩展。

五、开始项目构建

只保留src文件夹下的 main.ts 以及 App.vue组件,

// src/App.vue
<script lang="ts">
</script>

<template>
  <div>App.vue</div>
</template>

<style>

</style>

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.mount('#app')

5.1 准备工作

  • 假如项目使用styl / stylus 作为css的预处理器
$ cnpm i stylus -D
  • 回顾移动端布局

    • 弹性盒布局

      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      
    • rem布局

      html {
        font-size: 100px;
      }
      div {
        /* height: 50px; */
        height: 0.5rem;
      }
      

      ? rem以及em布局的区别

    • VW/VH布局

      html {
        font-size: 100px;
        /* 100 / 375 * 100   100 进行375等分,然后乘以好计算的100 */
        /* iphone6 750 * 1334  375 * 667*/
        /* iphne5 640 * 1136  320 * 568  100/320*100 = 31.25vw*/
        /* font-size: 26.666667vw; */ /* iphone6  1rem=100px 其余机型自动适配*/
      }
      body {
        /* 设计稿中的最小的字体大小 */
        font-size: 12px;
      }
      div {
        /* height: 50px; */
        height: 0.5rem;
      }
      
    • 媒体查询

      @media screen and (max-width: 300px) {
          body {
              background-color:lightblue;
          }
      }
      @media only screen and (orientation: landscape) { // 横屏  portrait 竖屏
        html {
          font-size: 100px;
        }
      }
      
  • ts基础知识(类型注解、类型断言、!、?、interface、type、声明文件*.d.ts 。。。。。。)

    https://www.bilibili.com/video/BV1H44y157gq/?spm_id_from=333.337.search-card.all.click

5.2 构建移动端基本页面结构

image-20230103102322118
5.2.1 基本构建

编写重置样式表如下:

// src/assets/main.styl
* {
  padding: 0;
  margin: 0;// src/assets/main.styl
// stylus语法考验写代码的规范
*
  padding 0
  margin 0
  list-style none

html
  height 100%
  font-size 26.6666667vw

  body
    height 100%
    font-size 14px

    #app 
      height 100%
  list-style: none;
  text-decoration: none;
}

html, body, #app {
  height: 100%;
}

项目入口文件代码如下:

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

import './assets/main.styl'

const app = createApp(App)

app.mount('#app')

项目根组件如下:

<!-- src/App.vue -->
<script lang="ts">
</script>

<template>
  <div class="container">
    <div class="box">
      <header class="header">header</header>
      <div class="content">content</div>
    </div>
    <footer class="footer">footer</footer>
  </div>
</template>

<style lang="stylus">
.container
  height 100%
  display flex
  flex-direction column
  .box
    flex 1
    display flex
    flex-direction column
    .header
      height 0.44rem
      background-color #f66
    .content
      flex 1
  .footer
    height 0.5rem
    background-color #efefef
</style>

运行项目,发现页面呈现上中下结构,这就是我们的移动端页面的主要布局了。

那么问题来了,假如用户把手机横屏,这个时候如果还是使用vw和rem布局的话就用容易出问题了。(通过点击模拟器旋转屏幕按钮查看)

5.2.2 媒体查询处理横屏

可以设置横屏情况下不采用vw布局或者给用户展示一个提示信息

// src/assets/main.styl
// stylus语法考验写代码的规范
*
  padding 0
  margin 0
  list-style none

html
  height 100%
  font-size 26.6666667vw

  body
    height 100%
    font-size 14px

    #app 
      height 100%

@media only screen and (orientation landscape)  // 横屏
  // 横屏状态下不要使用vw布局
  html 
    font-size 100px
  

根组件这样写:

<!-- src/App.vue -->
<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({})
</script>

<template>
  <div class="container">
    <div class="box">
      <header class="header">header</header>
      <div class="content">content</div>
    </div>
    <footer class="footer">footer</footer>
  </div>
  <div class="landscape-tip">
    请将屏幕竖向浏览
  </div>
</template>

<style lang="stylus">
.container
  height 100%
  display flex
  flex-direction column
  .box
    flex 1
    display flex
    flex-direction column
    .header
      height 0.44rem
      background-color #f66
    .content
      flex 1
  .footer
    height 0.5rem
    background-color #efefef
    user-select none

.landscape-tip
  position fixed
  top 0
  right 0
  bottom 0
  left 0
  background-color rgba(0, 0, 0, .8)
  color #fff
  display none
  justify-content center
  align-items center

@media only screen and (orientation landscape)  // 横屏
  // 横屏状态下使用flex布局,其他情况不显示
  .landscape-tip 
    display flex
 
</style>

现在审查元素 查看css 没有自动补全样式,如何通过配置自动补全css

5.2.4 自动补全css
$ cnpm i postcss postcss-preset-env -D

项目根目录下创建 postcss.config.js

// postcss.config.js
module.exports = {
  plugins: [
   require('postcss-preset-env')
  ]
}

给页面的底部添加 user-select: none,重启项目,审查元素看前后的变化

// 未配置 postcss 时,渲染为
.container .footer {
    height: 0.5rem;
    background-color: #efefef;
    user-select: none;
}
// 配置过 postcss 时,渲染为
.container .footer {
    height: 0.5rem;
    background-color: #efefef;
    -webkit-user-select: none;
    -moz-user-select: none;
    user-select: none;
}

5.3 构建项目基本页面

思考每个页面的头部和内容区域是根据用户的选择而一起改变的,那么可以创建以下四个基本页面

5.3.1 构建首页面
<!-- src/views/home/index.vue -->
<template>
  <div class="box">
    <header class="header">home header</header>
    <div class="content">home content</div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';

export default defineComponent({})
</script>

<style lang='stylus'>
  
</style>
5.3.2 构建分类页面
<!-- src/views/kind/index.vue -->
<template>
  <div class="box">
    <header class="header">kind header</header>
    <div class="content">kind content</div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';

export default defineComponent({})
</script>

<style lang='stylus'>
  
</style>
5.3.3 构建购物车页面
<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">cart header</header>
    <div class="content">cart content</div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';

export default defineComponent({})
</script>

<style lang='stylus'>
  
</style>
5.3.4 构建个人中心页面
<!-- src/views/user/index.vue -->
<template>
  <div class="box">
    <header class="header">user header</header>
    <div class="content">user content</div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';

export default defineComponent({})
</script>

<style lang='stylus'>
  
</style>

5.4 引入路由

创建项目时,已经选择过使用vue-router,所以不需要安装

如果没有选择,那么使用以下语句安装

$ cnpm i vue-router@4 -S

然后再配置

5.4.1 创建路由

一般情况下,会将路由设置到 src/router/index.ts 文件中

// src/router/index.ts
import  { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, Router } from 'vue-router'
// 直接引入组件,不管你用不用这个页面,我都引入了 --- 静态导入
import Home from '@/views/home/index.vue'
// import Kind from '@/views/kind/index.vue'
// import Cart from '@/views/cart/index.vue'
// import User from '@/views/user/index.vue'
// 使用路由懒加载 - 访问该路由时才加载该组件    ---  动态导入
const Kind = () => import('@/views/kind/index.vue')
const Cart = () => import('@/views/cart/index.vue')
const User = () => import('@/views/user/index.vue')

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home',
    name: 'home',
    component: Home
  },
  {
    path: '/kind',
    name: 'kind',
    component: Kind
  },
  {
    path: '/cart',
    name: 'cart',
    component: Cart
  },
  {
    path: '/user',
    name: 'user',
    component: User
  }
]

const router: Router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
5.4.2 入口文件中使用路由

调用路由以插件的形式

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router'

import './assets/main.styl'

const app = createApp(App)

app.use(router)

app.mount('#app')

5.4.3 路由出口

vue-router内置了<router-view></router-view>组件

router-view 将显示与 url 对应的组件。你可以把它放在任何地方,以适应你的布局

<!-- src/App.vue -->
<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({})
</script>

<template>
  <div class="container">
    <!-- <div class="box">
      <header class="header">header</header>
      <div class="content">content</div>
    </div> -->
    <RouterView />
    <footer class="footer">footer</footer>
  </div>
  <div class="landscape-tip">
    请将屏幕竖向浏览
  </div>
</template>

<style lang="stylus">
.container
  height 100%
  display flex
  flex-direction column
  .box
    flex 1
    display flex
    flex-direction column
    .header
      height 0.44rem
      background-color #f66
    .content
      flex 1
  .footer
    height 0.5rem
    background-color #efefef
    user-select none

.landscape-tip
  position fixed
  top 0
  right 0
  bottom 0
  left 0
  background-color rgba(0, 0, 0, .8)
  color #fff
  display none
  justify-content center
  align-items center

@media only screen and (orientation landscape)  // 横屏
  // 横屏状态下使用flex布局,其他情况不显示
  .landscape-tip 
    display flex
 
</style>

此时地址栏分别输入

http://127.0.0.1:5173/homehttp://127.0.0.1:5173/kindhttp://127.0.0.1:5173/carthttp://127.0.0.1:5173/user

查看项目运行结果,

可以得知已经可以通过路由显示不同的页面

但是用户一般都是通过底部选项卡来切换页面的

5.4.4 构建页面底部组件

src文件夹下创建components文件夹,在components文件夹下创建底部组件

因为底部选项卡需要字体图标,可以选择 iconfont阿里字体图标库,搜索图标,加入购物车,添加至项目mobile-vue-app,选择font-class,点击查看在线链接,拷贝css链接

项目根目录下index.html中引入css链接

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vite App</title>
    <link rel="stylesheet" href="//at.alicdn.com/t/c/font_3665887_h3lsrioddkk.css">
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

底部组件展示如下:

<!-- src/components/Footer.vue -->
<template>
  <footer class="footer">
    <ul>
      <li>
        <span class="iconfont icon-shouye"></span>
        <p>首页</p>
      </li>
      <li>
        <span class="iconfont icon-fenlei"></span>
        <p>分类</p>
      </li>
      <li>
        <span class="iconfont icon-gouwuche"></span>
        <p>购物车</p>
      </li>
      <li>
        <span class="iconfont icon-shouye1"></span>
        <p>我的</p>
      </li>
    </ul>
  </footer>
</template>

根组件引入底部组件

<!-- src/App.vue -->
<script lang="ts">
import { defineComponent } from 'vue'
import Footer from '@/components/Footer.vue'
export default defineComponent({
  components: {
    Footer
  }
})
</script>

<template>
  <div class="container">
    <!-- <div class="box">
      <header class="header">header</header>
      <div class="content">content</div>
    </div> -->
    <RouterView />
    <!-- <footer class="footer">footer</footer> -->
    <Footer />
  </div>
  <div class="landscape-tip">
    请将屏幕竖向浏览
  </div>
</template>

<style lang="stylus">
.container
  height 100%
  display flex
  flex-direction column
  .box
    flex 1
    display flex
    flex-direction column
    .header
      height 0.44rem
      background-color #f66
    .content
      flex 1
  .footer
    height 0.5rem
    background-color #efefef
    user-select none
    ul
      width 100%
      height 100%
      display flex
      li
        flex 1
        display flex
        flex-direction column
        justify-content center
        align-items center
        span
          font-size 0.24rem

.landscape-tip
  position fixed
  top 0
  right 0
  bottom 0
  left 0
  background-color rgba(0, 0, 0, .8)
  color #fff
  display none
  justify-content center
  align-items center

@media only screen and (orientation landscape)  // 横屏
  // 横屏状态下使用flex布局,其他情况不显示
  .landscape-tip 
    display flex
 
</style>
5.4.5 声明式导航跳转页面

声明式跳转 a href

编程式跳转 window.location.href = ‘’

vue-router中提供了<router-link>组件来完成页面的声明式跳转。

vue2 - vue-router3 <router-link to="/kind"></router-link> ===> <a href="/kind"></a>

<router-link to="/kind" tag="p"></router-link> ===> <p></p>

vue3 - vue-router4 移除了 tag 属性

<router-link>会默认渲染为a标签,并且为了凸显出用户选择了哪一个选项,需要使用到vue-routerrouter-link组件的customv-slot的属性,且需要解构出isActivehrefnavigate等属性,具体代码如下

参考:https://router.vuejs.org/zh/api/#custom

<!-- src/components/Footer.vue -->
<template>
  <footer class="footer">
    <ul>
      <RouterLink to="/home" custom v-slot="{ href, isActive, navigate }">
        <li :href="href" @click="navigate" :class="isActive ? 'active': ''">
          <span class="iconfont icon-shouye"></span>
          <p>首页</p>
        </li>
      </RouterLink>
      <RouterLink to="/kind" custom v-slot="{ href, isActive, navigate }">
        <li :href="href" @click="navigate" :class="isActive ? 'active': ''">
          <span class="iconfont icon-fenlei"></span>
          <p>分类</p>
        </li>
      </RouterLink>
      <RouterLink to="/cart" custom v-slot="{ href, isActive, navigate }">
        <li :href="href" @click="navigate" :class="isActive ? 'active': ''">
          <span class="iconfont icon-gouwuche"></span>
          <p>购物车</p>
        </li>
      </RouterLink>
      <RouterLink to="/user" custom v-slot="{ href, isActive, navigate }">
        <li :href="href" @click="navigate" :class="isActive ? 'active': ''">
          <span class="iconfont icon-shouye1"></span>
          <p>我的</p>
        </li>
      </RouterLink> 
    </ul>
  </footer>
</template>
<!-- src/App.vue -->
<script lang="ts">
import { defineComponent } from 'vue'
import Footer from '@/components/Footer.vue'
export default defineComponent({
  components: {
    Footer
  }
})
</script>

<template>
  <div class="container">
    <!-- <div class="box">
      <header class="header">header</header>
      <div class="content">content</div>
    </div> -->
    <RouterView />
    <!-- <footer class="footer">footer</footer> -->
    <Footer />
  </div>
  <div class="landscape-tip">
    请将屏幕竖向浏览
  </div>
</template>

<style lang="stylus">
.container
  height 100%
  display flex
  flex-direction column
  .box
    flex 1
    display flex
    flex-direction column
    .header
      height 0.44rem
      background-color #f66
    .content
      flex 1
  .footer
    height 0.5rem
    background-color #efefef
    user-select none
    ul
      width 100%
      height 100%
      display flex
      li
        flex 1
        display flex
        flex-direction column
        justify-content center
        align-items center
        &.active // 底部选项卡选中的样式
          color #f66
        span
          font-size 0.24rem

.landscape-tip
  position fixed
  top 0
  right 0
  bottom 0
  left 0
  background-color rgba(0, 0, 0, .8)
  color #fff
  display none
  justify-content center
  align-items center

@media only screen and (orientation landscape)  // 横屏
  // 横屏状态下使用flex布局,其他情况不显示
  .landscape-tip 
    display flex
 
</style>

vue-router 3版本中 只需要通过一个tag属性即可生成目标标签。

5.5 引入UI组件库

使用vue实现移动端项目,最好的组件库是 vant,以前还有一个叫做 mint-ui

官网:https://vant-contrib.gitee.io/vant/#/zh-CN

5.5.1 介绍

轻量、可靠的移动端 Vue 组件库

5.5.2 快速上手
  • 安装

    $ cnpm i vant -S
    
  • 按需引入组件

    $ cnpm i unplugin-vue-components -D
    

    基于 vite 的项目,在 vite.config.js 文件中配置插件

    // vite.config.ts
    import { fileURLToPath, URL } from 'node:url'
    
    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import vueJsx from '@vitejs/plugin-vue-jsx'
    import Components from 'unplugin-vue-components/vite';
    import { VantResolver } from 'unplugin-vue-components/resolvers';
    
    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [
        vue(), 
        vueJsx(),
        Components({
          resolvers: [VantResolver()],
        }),
      ],
      resolve: {
        alias: {
          '@': fileURLToPath(new URL('./src', import.meta.url))
        }
      }
    })
    
    
    
  • 引入函数组件的样式

​ Vant 中有个别组件是以函数的形式提供的,包括 ToastDialogNotifyImagePreview 组件。在使用函数组件时,unplugin-vue-components 无法自动引入对应的样式,因此需要手动引入样式

你可以在项目的入口文件或公共模块中引入以上组件的样式,这样在业务代码中使用组件时,便不再需要重复引入样式了。

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router'

import 'vant/es/toast/style'
import 'vant/es/dialog/style'
import 'vant/es/notify/style'
import 'vant/es/image-preview/style'

import './assets/main.styl'

const app = createApp(App)

app.use(router)

app.mount('#app')

首页组件测试

<!-- src/views/home/index.vue -->
<template>
  <div class="box">
    <header class="header">home header</header>
    <div class="content">
      <van-button type="success" @click="test">成功按钮</van-button>
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showToast } from 'vant'
export default defineComponent({
  methods: {
    test () {
      showToast({
        message: '底部展示',
        position: 'bottom',
      });
    }
  }
})
</script>

<style lang='stylus'>
  
</style>

5.6 封装数据请求

在vue/react项目中建议使用 axios 作为数据请求的方案

axios官网:http://www.axios-js.com/

5.6.1 介绍

Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。

  • 从浏览器中创建 XMLHttpRequests
  • 从 node.js 创建 http 请求
  • 支持 Promise API
  • 拦截请求和响应
  • 转换请求数据和响应数据
  • 取消请求
  • 自动转换 JSON 数据
  • 客户端支持防御 XSRF
5.6.2 安装
$ cnpm i axios -S
5.6.3 案例

执行 GET 请求

// 为给定 ID 的 user 创建请求
axios.get('/user?ID=12345')
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

// 上面的请求也可以这样做
axios.get('/user', {
    params: {
      ID: 12345
    }
  })
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

// axios
axios({
    url: '/user?ID=12345',
    method: 'GET',
}).then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });
// 也可以这么写
axios({
    url: '/user',
    method: 'GET',
    data: {
        ID: '12345'
    }
}).then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

执行post请求

axios.post('/user', {
    firstName: 'Fred',
    lastName: 'Flintstone'
  })
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

// axios
axios({
    url: '/user',
    method: 'POST',
    data: {
        firstName: 'Fred',
    	lastName: 'Flintstone'
    }
}).then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

执行多个并发请求

function getUserAccount() {
  return axios.get('/user/12345');
}

function getUserPermissions() {
  return axios.get('/user/12345/permissions');
}

axios.all([getUserAccount(), getUserPermissions()])
  .then(axios.spread(function (acct, perms) {
    // 两个请求现在都执行完成
  }));
5.6.4 axios项目封装

一般封装时,需要判断用户当前所处的环境:开发环境、测试环境、生产环境

开发环境:http://localhost:5173 开发阶段

生产环境: https://www.baidu.com 项目上线,被人通过 ip或者域名访问项目

process.env.NODE_ENV 判断环境。development production

如果封装过程中 出现 找不到名称“process”。是否需要为节点安装类型定义? 请尝试使用 npm i --save-dev @types/node,然后将 “node” 添加到类型字段。

$ cnpm i @types/node -D

修改 tsconfig.json

{
  "extends": "@vue/tsconfig/tsconfig.web.json",
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": [ // ++++++++
      "node"
    ]
  },

  "references": [
    {
      "path": "./tsconfig.config.json"
    }
  ]
}

完整封装代码如下: http://121.89.205.189:3000/apidoc/

// src/utils/request.ts
import axios from 'axios'

const isDev = process.env.NODE_ENV === 'development' // 真 - 开发环境,假-生产环境

// http://121.89.205.189:3000/apidoc/
// npm run dev  走?后的
// npm run build 走:后的
const ins = axios.create({
  baseURL: isDev ? 'http://121.89.205.189:3000/api' : 'http://121.89.205.189:3000/api'
})

// 拦截器
ins.interceptors.request.use((config) => {
  return config
}, (err) => {
  return Promise.reject(err)
})

ins.interceptors.response.use((response) => {
  return response
}, (err) => {
  return Promise.reject(err)
})

export default ins

5.7 构建首页

5.7.1 封装首页相关数据请求

接口文档:http://121.89.205.189:3000/apidoc/

需要使用接口

  • 查看轮播图:http://121.89.205.189:3000/apidoc/#api-Banner-GetBannerList
  • 获取秒杀产品列表数据:http://121.89.205.189:3000/apidoc/#api-Pro-GetProSeckillList
  • 获取产品分页列表数据:http://121.89.205.189:3000/apidoc/#api-Pro-GetProList

遵循模块化开发思想,将数据请求方法统一封装

// src/api/home.ts
import request from '@/utils/request'

interface IPager {
  count: number
  limitNum: number
}
// 轮播图数据
export function getBannerList () {
  return request.get('/banner/list')
}
// 秒杀列表数据
export function getSeckilllist (params?: IPager) {
  return request.get('/pro/seckilllist', { params })
}
// 产品列表数据
export function getProList (params?: IPager) {
  return request.get('/pro/list', { params })
}
5.7.2 构建首页轮播图组件以及渲染
  • 构建轮播图组件

    <!-- src/views/home/components/Banner.vue -->
    <template>
      <div>轮播图</div>
    </template>
    
  • 首页注册引入组件

    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <header class="header">home header</header>
        <div class="content">
          <Banner />
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    export default defineComponent({
      components: {
        Banner
      }
    })
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 首页请求数据以及传递数据

    // src/views/home/home.d.ts
    export interface IBanner {
      bannerid: string
      alt: string
      link: string
      flag: boolean
      img: string
    }
    
    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <header class="header">home header</header>
        <div class="content">
          <Banner :bannerList = "bannerList"/>
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    import { getBannerList } from '@/api/home'
    import type { IBanner } from './home'
    interface IData {
      bannerList: IBanner[]
    }
    export default defineComponent({
      components: {
        Banner
      },
      data (): IData {
        return {
          bannerList: []
        }
      },
      mounted () {
        getBannerList().then(res => {
          console.log(res.data)
          this.bannerList = res.data.data
        })
      }
    })
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 渲染轮播图组件

    <!-- src/views/home/components/Banner.vue -->
    <template>
      <div class="my-swipers">
        <van-swipe :autoplay="3000" indicator-color="white">
          <van-swipe-item v-for="item of bannerList" :key="item.bannerid">
            <van-image
              fit="fill"
              class="banner-img"
              :src="item.img"
            />
          </van-swipe-item>
        </van-swipe>
      </div>
    </template>
    
    <script lang='ts'>
    import { defineComponent } from 'vue';
    import type { PropType} from 'vue'
    import type { IBanner } from '../home'
    export default defineComponent({
      props: {
        // bannerList: Array, // js环境
        bannerList: Array as PropType<IBanner[]> // ts环境
      }
    })
    </script>
    
    <style lang="stylus">
    .my-swipers
      width 94%
      height 1.6rem
      margin-left 3%
      background-color #00f
      overflow hidden
      border-radius 10px
      .van-swipe
        height 100%
        .banner-img
          width 100%
          height 100%
    </style>
    
5.7.3 构建nav导航组件以及渲染
  • 构建nav导航组件

    <!-- src/views/home/components/Nav.vue -->
    <template>
      <div>nav</div>
    </template>
    
  • 首页引入以及注册nav导航组件

    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <header class="header">home header</header>
        <div class="content">
          <Banner :bannerList = "bannerList"/>
          <Nav/>
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    import Nav from './components/Nav.vue'
    import { getBannerList } from '@/api/home'
    import type { IBanner } from './home'
    interface IData {
      bannerList: IBanner[]
    }
    export default defineComponent({
      components: {
        Banner,
        Nav
      },
      data (): IData {
        return {
          bannerList: []
        }
      },
      mounted () {
        getBannerList().then(res => {
          console.log(res.data)
          this.bannerList = res.data.data
        })
      }
    })
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 首页准备nav导航数据并且传递

    // src/views/home/home.d.ts
    export interface IBanner {
      bannerid: string
      alt: string
      link: string
      flag: boolean
      img: string
    }
    
    export interface INav {
      navid: number
      title: string
      imgurl: string
    }
    
    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <header class="header">home header</header>
        <div class="content">
          <Banner :bannerList = "bannerList"/>
          <Nav :navList="navList"/>
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    import Nav from './components/Nav.vue'
    import { getBannerList } from '@/api/home'
    import type { IBanner, INav } from './home'
    interface IData {
      bannerList: IBanner[]
      navList: INav[]
    }
    export default defineComponent({
      components: {
        Banner,
        Nav
      },
      data (): IData {
        return {
          bannerList: [],
          navList: [
              { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
              { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
              { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
              { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
              { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
              { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
              { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
              { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
              { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
              { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
            ]
        }
      },
      mounted () {
        getBannerList().then(res => {
          console.log(res.data)
          this.bannerList = res.data.data
        })
      }
    })
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • nav导航组件的渲染

    <!-- src/views/home/components/Nav.vue -->
    <template>
      <van-grid :column-num="5" icon-size="44" :border="false" :square="true">
        <van-grid-item v-for="item in navList" :key="item.navid" :icon="item.imgurl" :text="item.title" />
      </van-grid>
    </template>
    
    <script lang='ts'>
    import { defineComponent } from 'vue';
    import type { PropType } from 'vue'
    import type { INav } from '../home'
    export default defineComponent({
      props: {
        navList: Array as PropType<INav[]>
      }
    })
    </script>
    
5.7.4 构建秒杀列表组件以及渲染
  • 构建秒杀列表组件

    <!-- src/views/home/components/Seckill.vue -->
    <template>
      <div>秒杀</div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue';
    
    export default defineComponent({})
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 首页组件引入以及注册秒杀列表组件

    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <header class="header">home header</header>
        <div class="content">
          <Banner :bannerList = "bannerList"/>
          <Nav :navList="navList"/>
          <Seckill />
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    import Nav from './components/Nav.vue'
    import Seckill from './components/Seckill.vue'
    import { getBannerList } from '@/api/home'
    import type { IBanner, INav } from './home'
    interface IData {
      bannerList: IBanner[]
      navList: INav[]
    }
    export default defineComponent({
      components: {
        Banner,
        Nav,
        Seckill
      },
      data (): IData {
        return {
          bannerList: [],
          navList: [
              { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
              { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
              { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
              { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
              { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
              { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
              { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
              { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
              { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
              { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
            ]
        }
      },
      mounted () {
        getBannerList().then(res => {
          console.log(res.data)
          this.bannerList = res.data.data
        })
      }
    })
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 首页请求秒杀数据以及传递

    // src/views/home/home.d.ts
    export interface IBanner {
      bannerid: string
      alt: string
      link: string
      flag: boolean
      img: string
    }
    
    export interface INav {
      navid: number
      title: string
      imgurl: string
    }
    
    export interface IPro {
      banners: string[]
      brand: string
      category: string
      desc: string
      discount: number
      img1: string
      img2: string
      img3: string
      img4: string
      isrecommend: number
      issale: number
      isseckill: number
      originprice: number
      proid: string
      proname: string
      sales: number
      stock: number
    }
    
    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <header class="header">home header</header>
        <div class="content">
          <Banner :bannerList = "bannerList"/>
          <Nav :navList="navList"/>
          <Seckill :seckillList="seckillList"/>
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    import Nav from './components/Nav.vue'
    import Seckill from './components/Seckill.vue'
    import { getBannerList, getSeckilllist } from '@/api/home'
    import type { IBanner, INav, IPro } from './home'
    interface IData {
      bannerList: IBanner[]
      navList: INav[]
      seckillList: IPro[]
    }
    export default defineComponent({
      components: {
        Banner,
        Nav,
        Seckill
      },
      data (): IData {
        return {
          bannerList: [],
          navList: [
            { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
            { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
            { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
            { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
            { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
            { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
            { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
            { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
            { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
            { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
          ],
          seckillList: []
        }
      },
      mounted () {
        getBannerList().then(res => {
          // console.log(res.data)
          this.bannerList = res.data.data
        })
        getSeckilllist().then(res => {
          console.log(res.data)
          this.seckillList = res.data.data
        })
      }
    })
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 秒杀列表组件渲染

    <!-- src/views/home/components/Seckill.vue -->
    <template>
      <div class="seckillList">
        <h4>
          嗨购秒杀
        </h4>
        <ul class="list">
          <li v-for="item of seckillList" :key="item.proid">
            <van-image :src="item.img1"></van-image>
            <p>¥{{ item.originprice }}</p>
          </li>
        </ul>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue';
    import type { PropType } from 'vue';
    import type { IPro } from '../home';
    
    export default defineComponent({
      props: {
        seckillList: Array as PropType<IPro[]>
      }
    })
    </script>
    
    <style lang='stylus'>
    .seckillList
      height 1.1rem
      overflow hidden
      background-color #fff
      margin-top 10px
      .list
        width 100%
        display flex
        li
          flex 1
          .van-image
            width 0.6rem
            height 0.6rem
          p
            text-align center
            color #f66
    </style>
    

    作业:实现倒计时

5.7.5 构建产品列表组件以及渲染
  • 构建产品列表组件

    <!-- src/views/home/components/Pro.vue -->
    <template>
      <div>产品列表</div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue';
    
    export default defineComponent({})
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 注册组件以及使用产品列表组件

    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <header class="header">home header</header>
        <div class="content">
          <Banner :bannerList = "bannerList"/>
          <Nav :navList="navList"/>
          <Seckill :seckillList="seckillList"/>
          <Pro/>
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    import Nav from './components/Nav.vue'
    import Seckill from './components/Seckill.vue'
    import Pro from './components/Pro.vue'
    import { getBannerList, getSeckilllist } from '@/api/home'
    import type { IBanner, INav, IPro } from './home'
    interface IData {
      bannerList: IBanner[]
      navList: INav[]
      seckillList: IPro[]
    }
    export default defineComponent({
      components: {
        Banner,
        Nav,
        Seckill,
        Pro
      },
      data (): IData {
        return {
          bannerList: [],
          navList: [
            { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
            { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
            { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
            { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
            { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
            { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
            { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
            { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
            { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
            { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
          ],
          seckillList: []
        }
      },
      mounted () {
        getBannerList().then(res => {
          // console.log(res.data)
          this.bannerList = res.data.data
        })
        getSeckilllist().then(res => {
          // console.log(res.data)
          this.seckillList = res.data.data
        })
      }
    })
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 请求列表组件的数据以及传递

    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <header class="header">home header</header>
        <div class="content">
          <Banner :bannerList = "bannerList"/>
          <Nav :navList="navList"/>
          <Seckill :seckillList="seckillList"/>
          <Pro :proList="proList"/>
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    import Nav from './components/Nav.vue'
    import Seckill from './components/Seckill.vue'
    import Pro from './components/Pro.vue'
    import { getBannerList, getSeckilllist, getProList } from '@/api/home'
    import type { IBanner, INav, IPro } from './home'
    interface IData {
      bannerList: IBanner[]
      navList: INav[]
      seckillList: IPro[]
      proList: IPro[]
    }
    export default defineComponent({
      components: {
        Banner,
        Nav,
        Seckill,
        Pro
      },
      data (): IData {
        return {
          bannerList: [],
          navList: [
            { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
            { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
            { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
            { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
            { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
            { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
            { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
            { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
            { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
            { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
          ],
          seckillList: [],
          proList: []
        }
      },
      mounted () {
        getBannerList().then(res => {
          // console.log(res.data)
          this.bannerList = res.data.data
        })
        getSeckilllist().then(res => {
          // console.log(res.data)
          this.seckillList = res.data.data
        })
        getProList().then(res => {
          // console.log(res.data)
          this.proList = res.data.data
        })
      }
    })
    </script>
    
    <style lang='stylus'>
      
    </style>
    
  • 渲染列表组件

    <!-- src/views/home/components/Pro.vue -->
    <template>
      <ul class="proList">
        <li class="proItem" v-for="item of proList" :key="item.proid">
          <div class="itemImage">
            <van-image :src="item.img1"></van-image>
          </div>
          <div class="itemInfo">
            <div class="title van-multi-ellipsis--l2">{{ item.proname }}</div>
            <div class="price">¥{{ item.originprice }}</div>
            <div class="other">
              <van-tag type="danger">{{ item.category }}</van-tag>
            </div>
          </div>
        </li>
        <!-- <li class="proItem">
          <div class="itemImage">
            <van-image src=""></van-image>
          </div>
          <div class="itemInfo">
            <div class="title">产品名称</div>
            <div class="price">¥1999</div>
            <div class="other">苹果</div>
          </div>
        </li>
        <li class="proItem">
          <div class="itemImage">
            <van-image src=""></van-image>
          </div>
          <div class="itemInfo">
            <div class="title">产品名称</div>
            <div class="price">¥1999</div>
            <div class="other">苹果</div>
          </div>
        </li> -->
      </ul>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue';
    import type { PropType } from 'vue'
    import type { IPro } from '../home'
    
    export default defineComponent({
      props: {
        proList: Array as PropType<IPro[]>
      }
    })
    </script>
    
    <style lang='stylus'>
    .proList
      display flex
      flex-wrap wrap
      .proItem
        width 46%
        margin 8px 2%
        min-height 2.6rem
        background-color #fff
        border-radius 10px
        overflow hidden
        .itemImage
          width 100%
          height 1.9rem
          .van-image
            width 100%
            height 100%
            display block
        .itemInfo
          padding 10px
          .price 
            color #f66
            margin-top 5px
          .other
            margin-top 5px
    </style>
    

    因为列表的数据展示 内容超过了容器的高度,此时将 App.vue 中的box 以及 content中设置 overflow:auto

5.7.6 实现上拉加载功能

List 组件通过 loadingfinished 两个变量控制加载状态,当组件滚动到底部时,会触发 load 事件并将 loading 设置成 true。此时可以发起异步操作并更新数据,数据更新完毕后,将 loading 设置成 false 即可。若数据已全部加载完毕,则直接将 finished 设置成 true 即可。

隐藏条件 ,每页页码

<!-- src/views/home/index.vue -->
<template>
  <div class="box">
    <header class="header">home header</header>
    <div class="content">
      <Banner :bannerList = "bannerList"/>
      <Nav :navList="navList"/>
      <Seckill :seckillList="seckillList"/>
      <van-list
        v-model:loading="loading"
        :finished="finished"
        finished-text="没有更多了"
        @load="onLoad"
      >
        <Pro :proList="proList"/>
      </van-list>
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue'
import Banner from './components/Banner.vue'
import Nav from './components/Nav.vue'
import Seckill from './components/Seckill.vue'
import Pro from './components/Pro.vue'
import { getBannerList, getSeckilllist, getProList } from '@/api/home'
import type { IBanner, INav, IPro } from './home'
interface IData {
  bannerList: IBanner[]
  navList: INav[]
  seckillList: IPro[]
  proList: IPro[]
  loading: boolean
  finished: boolean
  count: number
}
export default defineComponent({
  components: {
    Banner,
    Nav,
    Seckill,
    Pro
  },
  data (): IData {
    return {
      bannerList: [],
      navList: [
        { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
        { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
        { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
        { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
        { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
        { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
        { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
        { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
        { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
        { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
      ],
      seckillList: [],
      proList: [],
      loading: false,
      finished: false,
      count: 2 // 默认已经请求第一页的数据,下一次从2开始
    }
  },
  mounted () {
    getBannerList().then(res => {
      // console.log(res.data)
      this.bannerList = res.data.data
    })
    getSeckilllist().then(res => {
      // console.log(res.data)
      this.seckillList = res.data.data
    })
    getProList().then(res => {
      // console.log(res.data)
      this.proList = res.data.data
    })
  },
  methods: {
    onLoad () {
      this.loading = true
      getProList({ count: this.count, limitNum: 10 }).then(res => {
        if (res.data.data.length === 0) {
          // 没有数据了
          this.finished = true
        } else {
          // 有数据 合并数据
          this.proList = [...this.proList, ...res.data.data ]
          this.count++
        }
        this.loading = false
      })
    }
  }
})
</script>

<style lang='stylus'>
  
</style>
5.7.7 实现下拉刷新功能

下拉刷新时会触发 refresh 事件,在事件的回调函数中可以进行同步或异步操作,操作完成后将 v-model 设置为 false,表示加载完成。

<!-- src/views/home/index.vue -->
<template>
  <div class="box">
    <header class="header">home header</header>
    <div class="content">
      <van-pull-refresh v-model="loading" @refresh="onRefresh">
        <Banner :bannerList = "bannerList"/>
        <Nav :navList="navList"/>
        <Seckill :seckillList="seckillList"/>
        <van-list
          :immediate-check="false"
          v-model:loading="loading"
          :finished="finished"
          finished-text="没有更多了"
          @load="onLoad"
        >
          <Pro :proList="proList"/>
        </van-list>
      </van-pull-refresh>
      
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue'
import Banner from './components/Banner.vue'
import Nav from './components/Nav.vue'
import Seckill from './components/Seckill.vue'
import Pro from './components/Pro.vue'
import { getBannerList, getSeckilllist, getProList } from '@/api/home'
import type { IBanner, INav, IPro } from './home'
interface IData {
  bannerList: IBanner[]
  navList: INav[]
  seckillList: IPro[]
  proList: IPro[]
  loading: boolean
  finished: boolean
  count: number
}
export default defineComponent({
  components: {
    Banner,
    Nav,
    Seckill,
    Pro
  },
  data (): IData {
    return {
      bannerList: [],
      navList: [
        { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
        { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
        { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
        { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
        { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
        { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
        { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
        { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
        { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
        { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
      ],
      seckillList: [],
      proList: [],
      loading: false,
      finished: false,
      count: 2 // 默认已经请求第一页的数据,下一次从2开始
    }
  },
  mounted () {
    getBannerList().then(res => {
      // console.log(res.data)
      this.bannerList = res.data.data
    })
    getSeckilllist().then(res => {
      // console.log(res.data)
      this.seckillList = res.data.data
    })
    getProList().then(res => {
      // console.log(res.data)
      this.proList = res.data.data
    })
  },
  methods: {
    onLoad () {
      this.loading = true
      getProList({ count: this.count, limitNum: 10 }).then(res => {
        if (res.data.data.length === 0) {
          // 没有数据了
          this.finished = true
        } else {
          // 有数据 合并数据
          this.proList = [...this.proList, ...res.data.data ]
          this.count++
        }
        this.loading = false
      })
    },
    onRefresh () {
      this.loading = true
      // 下拉刷新其实就是请求第一页的数据,要记得重置状态
      getProList().then(res => {
        // console.log(res.data)
        this.proList = res.data.data
        this.count = 2
        this.finished = false
        this.loading = false
      })
    }
  }
})
</script>

<style lang='stylus'>
  
</style>
5.7.8 实现返回顶部功能

分析清除到底是哪一个容器产生了滚动条

分析得知 content 容器产生了滚动条,可以给它绑定一个 scroll 事件用于判断 回到顶部按钮显示还是不显示

通过 content 的dom的scrollTop 属性可以设置滚动条距离

当使用vantui组件库4版本时使用如下代码

<!-- src/views/home/index.vue -->
<template>
  <div class="box">
    <header class="header">home header</header>
    <div class="content">
      <van-pull-refresh v-model="loading" @refresh="onRefresh">
        <Banner :bannerList = "bannerList"/>
        <Nav :navList="navList"/>
        <Seckill :seckillList="seckillList"/>
        <van-list
          :immediate-check="false"
          v-model:loading="loading"
          :finished="finished"
          finished-text="没有更多了"
          @load="onLoad"
        >
          <Pro :proList="proList"/>
        </van-list>
      </van-pull-refresh>
      <van-back-top target=".content" bottom="10vh" />
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue'
import Banner from './components/Banner.vue'
import Nav from './components/Nav.vue'
import Seckill from './components/Seckill.vue'
import Pro from './components/Pro.vue'
import { getBannerList, getSeckilllist, getProList } from '@/api/home'
import type { IBanner, INav, IPro } from './home'
interface IData {
  bannerList: IBanner[]
  navList: INav[]
  seckillList: IPro[]
  proList: IPro[]
  loading: boolean
  finished: boolean
  count: number
}
export default defineComponent({
  components: {
    Banner,
    Nav,
    Seckill,
    Pro
  },
  data (): IData {
    return {
      bannerList: [],
      navList: [
        { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
        { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
        { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
        { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
        { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
        { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
        { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
        { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
        { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
        { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
      ],
      seckillList: [],
      proList: [],
      loading: false,
      finished: false,
      count: 2 // 默认已经请求第一页的数据,下一次从2开始
    }
  },
  mounted () {
    getBannerList().then(res => {
      // console.log(res.data)
      this.bannerList = res.data.data
    })
    getSeckilllist().then(res => {
      // console.log(res.data)
      this.seckillList = res.data.data
    })
    getProList().then(res => {
      // console.log(res.data)
      this.proList = res.data.data
    })
  },
  methods: {
    onLoad () {
      this.loading = true
      getProList({ count: this.count, limitNum: 10 }).then(res => {
        if (res.data.data.length === 0) {
          // 没有数据了
          this.finished = true
        } else {
          // 有数据 合并数据
          this.proList = [...this.proList, ...res.data.data ]
          this.count++
        }
        this.loading = false
      })
    },
    onRefresh () {
      this.loading = true
      // 下拉刷新其实就是请求第一页的数据,要记得重置状态
      getProList().then(res => {
        // console.log(res.data)
        this.proList = res.data.data
        this.count = 2
        this.finished = false
        this.loading = false
      })
    }
  }
})
</script>

<style lang='stylus'>
  
</style>

如果使用4版本以前,参考如下代码

<!-- src/views/home/index.vue -->
<template>
  <div class="box">
    <header class="header">home header</header>
    <div class="content" @scroll="onScroll" ref="content">
      <van-pull-refresh v-model="loading" @refresh="onRefresh">
        <Banner :bannerList = "bannerList"/>
        <Nav :navList="navList"/>
        <Seckill :seckillList="seckillList"/>
        <van-list
          :immediate-check="false"
          v-model:loading="loading"
          :finished="finished"
          finished-text="没有更多了"
          @load="onLoad"
        >
          <Pro :proList="proList"/>
        </van-list>
      </van-pull-refresh>
      <!-- <van-back-top target=".content" bottom="10vh" /> -->
      <div class="backTop" @click="backTop" v-if="scrollTop > 300">
        <van-icon name="back-top" size="28"/>
      </div>
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue'
import Banner from './components/Banner.vue'
import Nav from './components/Nav.vue'
import Seckill from './components/Seckill.vue'
import Pro from './components/Pro.vue'
import { getBannerList, getSeckilllist, getProList } from '@/api/home'
import type { IBanner, INav, IPro } from './home'
interface IData {
  bannerList: IBanner[]
  navList: INav[]
  seckillList: IPro[]
  proList: IPro[]
  loading: boolean
  finished: boolean
  count: number
  scrollTop: number
}
export default defineComponent({
  components: {
    Banner,
    Nav,
    Seckill,
    Pro
  },
  data (): IData {
    return {
      bannerList: [],
      navList: [
        { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
        { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
        { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
        { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
        { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
        { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
        { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
        { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
        { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
        { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
      ],
      seckillList: [],
      proList: [],
      loading: false,
      finished: false,
      count: 2, // 默认已经请求第一页的数据,下一次从2开始
      scrollTop: 0
    }
  },
  mounted () {
    getBannerList().then(res => {
      // console.log(res.data)
      this.bannerList = res.data.data
    })
    getSeckilllist().then(res => {
      // console.log(res.data)
      this.seckillList = res.data.data
    })
    getProList().then(res => {
      // console.log(res.data)
      this.proList = res.data.data
    })
  },
  methods: {
    onLoad () {
      this.loading = true
      getProList({ count: this.count, limitNum: 10 }).then(res => {
        if (res.data.data.length === 0) {
          // 没有数据了
          this.finished = true
        } else {
          // 有数据 合并数据
          this.proList = [...this.proList, ...res.data.data ]
          this.count++
        }
        this.loading = false
      })
    },
    onRefresh () {
      this.loading = true
      // 下拉刷新其实就是请求第一页的数据,要记得重置状态
      getProList().then(res => {
        // console.log(res.data)
        this.proList = res.data.data
        this.count = 2
        this.finished = false
        this.loading = false
      })
    },
    onScroll () {
      this.scrollTop = (this.$refs.content as HTMLDivElement).scrollTop
    },
    backTop () {
      (this.$refs.content as HTMLDivElement).scrollTop = 0
    }
  }
})
</script>

<style lang='stylus'>
.backTop 
  position fixed
  bottom 0.6rem
  right 10px
  width 36px
  height 36px
  background-color #fff
  border 1px solid #efefef
  border-radius 50%
  display flex
  justify-content center
  align-items center
  user-select none
  
</style>
5.7.9 自定义头部
  • 自定义头部组件

    <!-- src/views/home/components/Header.vue -->
    <template>
      <header class="header">
        header
      </header>
    </template>
    
  • 注册及使用组件

    <!-- src/views/home/index.vue -->
    <template>
      <div class="box">
        <!-- <header class="header">home header</header> -->
        <Header />
        <div class="content" @scroll="onScroll" ref="content">
          <van-pull-refresh v-model="loading" @refresh="onRefresh">
            <Banner :bannerList = "bannerList"/>
            <Nav :navList="navList"/>
            <Seckill :seckillList="seckillList"/>
            <van-list
              :immediate-check="false"
              v-model:loading="loading"
              :finished="finished"
              finished-text="没有更多了"
              @load="onLoad"
            >
              <Pro :proList="proList"/>
            </van-list>
          </van-pull-refresh>
          <!-- <van-back-top target=".content" bottom="10vh" /> -->
          <div class="backTop" @click="backTop" v-if="scrollTop > 300">
            <van-icon name="back-top" size="28"/>
          </div>
        </div>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue'
    import Banner from './components/Banner.vue'
    import Nav from './components/Nav.vue'
    import Seckill from './components/Seckill.vue'
    import Pro from './components/Pro.vue'
    import Header from './components/Header.vue'
    import { getBannerList, getSeckilllist, getProList } from '@/api/home'
    import type { IBanner, INav, IPro } from './home'
    interface IData {
      bannerList: IBanner[]
      navList: INav[]
      seckillList: IPro[]
      proList: IPro[]
      loading: boolean
      finished: boolean
      count: number
      scrollTop: number
    }
    export default defineComponent({
      components: {
        Banner,
        Nav,
        Seckill,
        Pro,
        Header
      },
      data (): IData {
        return {
          bannerList: [],
          navList: [
            { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
            { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
            { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
            { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
            { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
            { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
            { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
            { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
            { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
            { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
          ],
          seckillList: [],
          proList: [],
          loading: false,
          finished: false,
          count: 2, // 默认已经请求第一页的数据,下一次从2开始
          scrollTop: 0
        }
      },
      mounted () {
        getBannerList().then(res => {
          // console.log(res.data)
          this.bannerList = res.data.data
        })
        getSeckilllist().then(res => {
          // console.log(res.data)
          this.seckillList = res.data.data
        })
        getProList().then(res => {
          // console.log(res.data)
          this.proList = res.data.data
        })
      },
      methods: {
        onLoad () {
          this.loading = true
          getProList({ count: this.count, limitNum: 10 }).then(res => {
            if (res.data.data.length === 0) {
              // 没有数据了
              this.finished = true
            } else {
              // 有数据 合并数据
              this.proList = [...this.proList, ...res.data.data ]
              this.count++
            }
            this.loading = false
          })
        },
        onRefresh () {
          this.loading = true
          // 下拉刷新其实就是请求第一页的数据,要记得重置状态
          getProList().then(res => {
            // console.log(res.data)
            this.proList = res.data.data
            this.count = 2
            this.finished = false
            this.loading = false
          })
        },
        onScroll () {
          this.scrollTop = (this.$refs.content as HTMLDivElement).scrollTop
        },
        backTop () {
          (this.$refs.content as HTMLDivElement).scrollTop = 0
        }
      }
    })
    </script>
    
    <style lang='stylus'>
    .backTop 
      position fixed
      bottom 0.6rem
      right 10px
      width 36px
      height 36px
      background-color #fff
      border 1px solid #efefef
      border-radius 50%
      display flex
      justify-content center
      align-items center
      user-select none
      
    </style>
    
  • 完善头部组件

    <!-- src/views/home/components/Header.vue -->
    <template>
      <header class="header">
        <ul>
          <li>太原</li>
          <li>
            <div class="searchBox">
              <van-image :src="logo" height="24"></van-image>
              <span class="divider ">|</span>
              <van-icon name="search" size="24"/>
              <span class="searchText">游戏主机</span>
            </div>
          </li>
          <li>登录</li>
        </ul>
      </header>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue';
    import logo from '@/assets/logo.png'
    export default defineComponent({
      data () {
        return {
          logo
        }
      }
    })
    </script>
    
    <style lang='stylus'>
    .header {
      ul {
        width: 100%;
        height: 100%;
        display: flex;
        li {
          height: 100%;
    
          display: flex;
          justify-content: center;
          align-items: center;
    
          color: #fff;
          
    
          &:nth-child(1), &:nth-child(3) {
            width: 50px;
          }
          &:nth-child(2) {
            flex: 1;
            .searchBox {
              width: 100%;
              height: 70%;
              background-color: #fff;
              border-radius: 16px;
              color: #666;
              display: flex;
              .van-image {
                width: 32px;
                margin-top: 4px;
                margin-left: 10px;
              }
              .divider {
                width: 12px;
                font-size: 22px;
                margin-left: 10px;
                color: #999;
              }
              .van-icon {
                width: 24px;
                display: flex;
                justify-content: center;
                align-items: center;
              }
              .searchText {
                flex: 1;
                line-height: .31rem;
                display: flex;
                align-items: center;
              }
            }
          }
        }
      }
    } 
    </style>
    
5.7.10 优化代码
// src/views/home/components/index.ts
// import Banner from './Banner.vue'
// import Nav from './Nav.vue'
// import Seckill from './Seckill.vue'
// import Pro from './Pro.vue'
// import Header from './Header.vue'

// export {
//   Banner,
//   Nav,
//   Seckill,
//   Pro,
//   Header
// }

export { default as Banner } from './Banner.vue'
export { default as Nav }  from './Nav.vue'
export { default as Seckill }  from './Seckill.vue'
export { default as Pro }  from './Pro.vue'
export { default as Header }  from './Header.vue'
<!-- src/views/home/index.vue -->
<template>
  <div class="box">
    <!-- <header class="header">home header</header> -->
    <Header />
    <div class="content" @scroll="onScroll" ref="content">
      <van-pull-refresh v-model="loading" @refresh="onRefresh">
        <Banner :bannerList = "bannerList"/>
        <Nav :navList="navList"/>
        <Seckill :seckillList="seckillList"/>
        <van-list
          :immediate-check="false"
          v-model:loading="loading"
          :finished="finished"
          finished-text="没有更多了"
          @load="onLoad"
        >
          <Pro :proList="proList"/>
        </van-list>
      </van-pull-refresh>
      <!-- <van-back-top target=".content" bottom="10vh" /> -->
      <div class="backTop" @click="backTop" v-if="scrollTop > 300">
        <van-icon name="back-top" size="28"/>
      </div>
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue'
// import Banner from './components/Banner.vue'
// import Nav from './components/Nav.vue'
// import Seckill from './components/Seckill.vue'
// import Pro from './components/Pro.vue'
// import Header from './components/Header.vue'
import  { Banner, Nav, Seckill, Pro, Header } from './components/index'
import { getBannerList, getSeckilllist, getProList } from '@/api/home'
import type { IBanner, INav, IPro } from './home'
interface IData {
  bannerList: IBanner[]
  navList: INav[]
  seckillList: IPro[]
  proList: IPro[]
  loading: boolean
  finished: boolean
  count: number
  scrollTop: number
}
export default defineComponent({
  components: {
    Banner,
    Nav,
    Seckill,
    Pro,
    Header
  },
  data (): IData {
    return {
      bannerList: [],
      navList: [
        { navid: 1, title: '嗨购超市', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/125678/35/5947/4868/5efbf28cEbf04a25a/e2bcc411170524f0.png' },
        { navid: 2, title: '数码电器', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/178015/31/13828/6862/60ec0c04Ee2fd63ac/ccf74d805a059a44.png' },
        { navid: 3, title: '嗨购服饰', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/41867/2/15966/7116/60ec0e0dE9f50d596/758babcb4f911bf4.png' },
        { navid: 4, title: '嗨购生鲜', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/177902/16/13776/5658/60ec0e71E801087f2/a0d5a68bf1461e6d.png' },
        { navid: 5, title: '嗨购到家', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196472/7/12807/7127/60ec0ea3Efe11835b/37c65625d94cae75.png' },
        { navid: 6, title: '充值缴费', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/185733/21/13527/6648/60ec0f31E0fea3e0a/d86d463521140bb6.png' },
        { navid: 7, title: '9.9元拼', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/36069/14/16068/6465/60ec0f67E155f9488/595ff3e606a53f02.png' },
        { navid: 8, title: '领券', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/186080/16/13681/8175/60ec0fcdE032af6cf/c5acd2f8454c40e1.png' },
        { navid: 9, title: '领金贴', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/196711/35/12751/6996/60ec1000E21b5bab4/38077313cb9eac4b.png' },
        { navid: 10, title: 'plus会员', imgurl: 'https://m.360buyimg.com/mobilecms/s120x120_jfs/t1/37709/6/15279/6118/60ec1046E4b5592c6/a7d6b66354efb141.png' }
      ],
      seckillList: [],
      proList: [],
      loading: false,
      finished: false,
      count: 2, // 默认已经请求第一页的数据,下一次从2开始
      scrollTop: 0
    }
  },
  mounted () {
    getBannerList().then(res => {
      // console.log(res.data)
      this.bannerList = res.data.data
    })
    getSeckilllist().then(res => {
      // console.log(res.data)
      this.seckillList = res.data.data
    })
    getProList().then(res => {
      // console.log(res.data)
      this.proList = res.data.data
    })
  },
  methods: {
    onLoad () {
      this.loading = true
      getProList({ count: this.count, limitNum: 10 }).then(res => {
        if (res.data.data.length === 0) {
          // 没有数据了
          this.finished = true
        } else {
          // 有数据 合并数据
          this.proList = [...this.proList, ...res.data.data ]
          this.count++
        }
        this.loading = false
      })
    },
    onRefresh () {
      this.loading = true
      // 下拉刷新其实就是请求第一页的数据,要记得重置状态
      getProList().then(res => {
        // console.log(res.data)
        this.proList = res.data.data
        this.count = 2
        this.finished = false
        this.loading = false
      })
    },
    onScroll () {
      this.scrollTop = (this.$refs.content as HTMLDivElement).scrollTop
    },
    backTop () {
      (this.$refs.content as HTMLDivElement).scrollTop = 0
    }
  }
})
</script>

<style lang='stylus'>
.backTop 
  position fixed
  bottom 0.6rem
  right 10px
  width 36px
  height 36px
  background-color #fff
  border 1px solid #efefef
  border-radius 50%
  display flex
  justify-content center
  align-items center
  user-select none
  
</style>

5.8 实现详情

5.8.1 构建详情页面以及详情路由

/detail/1 ======> /detail/:proid =====> this.$route.params.proid ====> params

/detail?proid=1 ======> /detail =====> this.$route.query.proid ====> query

vue中推荐大家使用 params 形式

  • 构建详情页面组件

    <!-- src/views/detail/index.vue -->
    <template>
      <header class="header">detail header</header>
      <div class="content">detail content</div>
    </template>
    
    <script lang="ts">
    import { defineComponent } from 'vue';
    
      export default defineComponent({})
    </script>
    
    <style lang="stylus"></style>
    
  • 添加详情路由

    // src/router/index.ts
    import  { createRouter, createWebHistory } from 'vue-router'
    import type { RouteRecordRaw, Router } from 'vue-router'
    // 直接引入组件,不管你用不用这个页面,我都引入了 --- 静态导入
    import Home from '@/views/home/index.vue'
    // import Kind from '@/views/kind/index.vue'
    // import Cart from '@/views/cart/index.vue'
    // import User from '@/views/user/index.vue'
    // 使用路由懒加载 - 访问该路由时才加载该组件    ---  动态导入
    const Kind = () => import('@/views/kind/index.vue')
    const Cart = () => import('@/views/cart/index.vue')
    const User = () => import('@/views/user/index.vue')
    const Detail = () => import('@/views/detail/index.vue')
    
    const routes: RouteRecordRaw[] = [
      {
        path: '/',
        redirect: '/home'
      },
      {
        path: '/home',
        name: 'home',
        component: Home
      },
      {
        path: '/kind',
        name: 'kind',
        component: Kind
      },
      {
        path: '/cart',
        name: 'cart',
        component: Cart
      },
      {
        path: '/user',
        name: 'user',
        component: User
      },
      {
        path: '/detail/:proid',
        name: 'detail',
        component: Detail
      }
    ]
    
    const router: Router = createRouter({
      history: createWebHistory(),
      routes
    })
    
    export default router
    

    地址栏输入 http://localhost:5173/detail/100

    但是此时发现,详情页面底部应该隐藏原来的底部的选项卡

5.8.2 改造路由实现页面底部自由

以上案例中 一个路由控制了一个区域的组件,那么可以一个路由控制多个区域的变化吗?

命名视图:https://router.vuejs.org/zh/guide/essentials/named-views.html

<router-view></router-view>
<router-view name="footer"></router-view>
{
	path: '',
	name: '',
	components: {
		default: '',
		footer: ''
	}
}
// src/router/index.ts
import  { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, Router } from 'vue-router'
// 直接引入组件,不管你用不用这个页面,我都引入了 --- 静态导入
import Home from '@/views/home/index.vue'
import Footer from '@/components/Footer.vue'
// import Kind from '@/views/kind/index.vue'
// import Cart from '@/views/cart/index.vue'
// import User from '@/views/user/index.vue'
// 使用路由懒加载 - 访问该路由时才加载该组件    ---  动态导入
const Kind = () => import('@/views/kind/index.vue')
const Cart = () => import('@/views/cart/index.vue')
const User = () => import('@/views/user/index.vue')
const Detail = () => import('@/views/detail/index.vue')

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home',
    name: 'home',
    // component: Home
    components: {
      default: Home,
      footer: Footer
    }
  },
  {
    path: '/kind',
    name: 'kind',
    // component: Kind
    components: {
      default: Kind,
      footer: Footer
    }
  },
  {
    path: '/cart',
    name: 'cart',
    // component: Cart
    components: {
      default: Cart,
      footer: Footer
    }
  },
  {
    path: '/user',
    name: 'user',
    // component: User
    components: {
      default: User,
      footer: Footer
    }
  },
  {
    path: '/detail/:proid',
    name: 'detail',
    component: Detail
  }
]

const router: Router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
<!-- src/App.vue -->
<script lang="ts">
import { defineComponent } from 'vue'
// import Footer from '@/components/Footer.vue'
export default defineComponent({
  components: {
    // Footer
  }
})
</script>

<template>
  <div class="container">
    <!-- <div class="box">
      <header class="header">header</header>
      <div class="content">content</div>
    </div> -->
    <RouterView />
    <!-- <footer class="footer">footer</footer> -->
    <!-- <Footer /> -->
    <RouterView name="footer"></RouterView>
  </div>
  <div class="landscape-tip">
    请将屏幕竖向浏览
  </div>
</template>

<style lang="stylus">
.container
  height 100%
  display flex
  flex-direction column
  .box
    flex 1
    display flex
    flex-direction column
    overflow auto
    .header
      height 0.44rem
      background-color #f66
    .content
      flex 1
      overflow auto
  .footer
    height 0.5rem
    background-color #efefef
    user-select none
    ul
      width 100%
      height 100%
      display flex
      li
        flex 1
        display flex
        flex-direction column
        justify-content center
        align-items center
        &.active
          color #f66
        span
          font-size 0.24rem

.landscape-tip
  position fixed
  top 0
  right 0
  bottom 0
  left 0
  background-color rgba(0, 0, 0, .8)
  color #fff
  display none
  justify-content center
  align-items center

@media only screen and (orientation landscape)  // 横屏
  // 横屏状态下使用flex布局,其他情况不显示
  .landscape-tip 
    display flex
 
</style>
5.8.3 点击列表进入产品详情
  • 点击秒杀列表声明式跳转至详情

    <a href=""></a>

    <router-link to="/detail/1"></router-link>

    <router-link :to="{ name: 'detail', params: { proid: 1 }}"></router-link>

    <router-link :to="{ path: 'detail/' + 1}"></router-link>

    <router-link :to=/detail/${1}></router-link>

    <!-- src/views/home/components/Seckill.vue -->
    <template>
      <div class="seckillList">
        <h4>
          嗨购秒杀
        </h4>
        <ul class="list">
          <!-- <RouterLink 
            custom
            v-slot="{ href, navigate }"
            v-for="item of seckillList" 
            :key="item.proid" 
            :to="{ name: 'detail', params: { proid: item.proid }}"
          >
            <li :href="href" @click="navigate">
              <van-image :src="item.img1"></van-image>
              <p>¥{{ item.originprice }}</p>
            </li>
          </RouterLink> -->
          <!-- <RouterLink 
            custom
            v-slot="{ href, navigate }"
            v-for="item of seckillList" 
            :key="item.proid" 
            :to="{ path: '/detail/' + item.proid }"
          >
            <li :href="href" @click="navigate">
              <van-image :src="item.img1"></van-image>
              <p>¥{{ item.originprice }}</p>
            </li>
          </RouterLink> -->
          <!-- <RouterLink 
            custom
            v-slot="{ href, navigate }"
            v-for="item of seckillList" 
            :key="item.proid" 
            :to="'/detail/' + item.proid"
          >
            <li :href="href" @click="navigate">
              <van-image :src="item.img1"></van-image>
              <p>¥{{ item.originprice }}</p>
            </li>
          </RouterLink> -->
          <RouterLink 
            custom
            v-slot="{ href, navigate }"
            v-for="item of seckillList" 
            :key="item.proid" 
            :to="`/detail/${item.proid}`"
          >
            <li :href="href" @click="navigate">
              <van-image :src="item.img1"></van-image>
              <p>¥{{ item.originprice }}</p>
            </li>
          </RouterLink>
          
        </ul>
      </div>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue';
    import type { PropType } from 'vue';
    import type { IPro } from '../home';
    
    export default defineComponent({
      props: {
        seckillList: Array as PropType<IPro[]>
      }
    })
    </script>
    
    <style lang='stylus'>
    .seckillList
      height 1.1rem
      overflow hidden
      background-color #fff
      margin-top 10px
      padding 5px 10px
      .list
        width 100%
        display flex
        li
          flex 1
          margin 3px
          .van-image
            width 100%
            height 0.55rem
          p
            text-align center
            color #f66
    </style>
    
  • 点击产品列表编程式跳转至详情

    window.location.href=""

    this.$router.push('/detail/1')

    this.$router.push({ name: 'detail', params: { proid: 1}})

    this.$router.push({ path: '/detail/1'})

    <!-- src/views/home/components/Pro.vue -->
    <template>
      <ul class="proList">
        <li class="proItem" v-for="item of proList" :key="item.proid" @click="toDetail(item.proid)">
          <div class="itemImage">
            <van-image :src="item.img1"></van-image>
          </div>
          <div class="itemInfo">
            <div class="title van-multi-ellipsis--l2">{{ item.proname }}</div>
            <div class="price">¥{{ item.originprice }}</div>
            <div class="other">
              <van-tag type="danger">{{ item.category }}</van-tag>
            </div>
          </div>
        </li>
        <!-- <li class="proItem">
          <div class="itemImage">
            <van-image src=""></van-image>
          </div>
          <div class="itemInfo">
            <div class="title">产品名称</div>
            <div class="price">¥1999</div>
            <div class="other">苹果</div>
          </div>
        </li>
        <li class="proItem">
          <div class="itemImage">
            <van-image src=""></van-image>
          </div>
          <div class="itemInfo">
            <div class="title">产品名称</div>
            <div class="price">¥1999</div>
            <div class="other">苹果</div>
          </div>
        </li> -->
      </ul>
    </template>
    <script lang='ts'>
    import { defineComponent } from 'vue';
    import type { PropType } from 'vue'
    import type { IPro } from '../home'
    
    export default defineComponent({
      props: {
        proList: Array as PropType<IPro[]>
      },
      methods: {
        toDetail (proid: string) {
          this.$router.push(`/detail/${proid}`)
          // this.$router.push('/detail/' + proid)
          // this.$router.push({ path: '/detail/' + proid })
          // this.$router.push({ name: 'detail', params: { proid: proid } })
        }
      }
    })
    </script>
    
    <style lang='stylus'>
    .proList
      display flex
      flex-wrap wrap
      .proItem
        width 46%
        margin 8px 2%
        min-height 2.6rem
        background-color #fff
        border-radius 10px
        overflow hidden
        .itemImage
          width 100%
          height 1.9rem
          .van-image
            width 100%
            height 100%
            display block
        .itemInfo
          padding 10px
          .price 
            color #f66
            margin-top 5px
          .other
            margin-top 5px
    </style>
    
5.8.4 详情页获取路由参数
<!-- src/views/detail/index.vue -->
<template>
  <div class="box">
    <header class="header">detail header</header>
    <div class="content">detail content</div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
interface IData {
  proid: string
}
export default defineComponent({
  data (): IData {
    return {
      proid: ''
    }
  },
  mounted () {
    console.log(this.$route.params)
    // this.proid = String(this.$route.params.proid)
    this.proid = this.$route.params.proid as string
  }
})
</script>

<style lang="stylus"></style>
5.8.5 封装详情页数据请求
// src/api/detail.ts
import request from '@/utils/request'

interface IPager {
  count: number
  limitNum?: number
}

export function getProDetail (proid: string) {
  return request.get('/pro/detail/' + proid)
}

// 详情 猜你喜欢 - 推荐
export function getRecommendList (params?: IPager) {
  return request.get('/pro/recommendlist', { params })
}
5.8.6 渲染详情页面
  • 请求数据
<!-- src/views/detail/index.vue -->
<template>
  <div class="box">
    <header class="header">detail header</header>
    <div class="content">detail content</div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { getProDetail } from '@/api/detail'
interface IData {
  proid: string
  banners: string[]
  brand: string
  category: string
  desc: string
  discount: number
  img1: string
  isrecommend: number
  issale: number
  isseckill: number
  originprice: number
  proname: string
  sales: number
  stock: number
}
export default defineComponent({
  data (): IData {
    return {
      proid: '',
      banners: [],
      brand: '',
      category: '',
      desc: '',
      discount: 0,
      img1: '',
      isrecommend: 0,
      issale: 0,
      isseckill: 0,
      originprice: 0,
      proname: '',
      sales: 0,
      stock: 0
    }
  },
  mounted () {
    console.log(this.$route.params)
    // this.proid = String(this.$route.params.proid)
    this.proid = this.$route.params.proid as string
    getProDetail(this.proid).then(res => {
      console.log(res.data.data)
      this.banners = res.data.data.banners[0].split(',')
      this.brand = res.data.data.brand
      this.category = res.data.data.category
      this.desc = res.data.data.desc
      this.discount = res.data.data.discount
      this.img1 = res.data.data.img1
      this.isrecommend = res.data.data.isrecommend
      this.issale = res.data.data.issale
      this.isseckill = res.data.data.isseckill
      this.originprice = res.data.data.originprice
      this.proname = res.data.data.proname
      this.sales = res.data.data.sales
      this.stock = res.data.data.stock
    })
  }
})
</script>

<style lang="stylus"></style>
5.8.7 渲染轮播图数据
  • 轮播图组件

    <!-- src/views/detail/components/Banner.vue -->
    <template>
      <div class="detail-swipers">
        <van-swipe indicator-color="white" @change="changeIndex" :initial-swipe="current">
          <van-swipe-item v-for="(item, index) of list" :key="index" @click="previewImage(index)">
            <van-image
              fit="fill"
              class="banner-img"
              :src="item"
            />
          </van-swipe-item>
        </van-swipe>
        <div class="indicator-tip">
          {{ current + 1 }} / {{ total }}
        </div>
      </div>
    </template>
    
    <script lang='ts'>
    import { defineComponent } from 'vue';
    import type { PropType} from 'vue'
    import { showImagePreview } from 'vant'
    export default defineComponent({
      props: {
        list: Array as PropType<string[]> // ts环境
      },
      data () {
        return {
          current: 0
        }
      },
      computed: {
        total () {
          return this.list!.length
        }
      },
      methods: {
        changeIndex (index: number) {
          this.current = index
        },
        previewImage (index: number) {
          showImagePreview({
            images: this.list!,
            startPosition: index,
            onChange: (idx: number) => { // 保证大图预览完毕 序号要一致 给 轮播图设置 initial-swipe 属性
              console.log('666', idx)
              this.current = idx
            }
          })
        }
      }
    })
    </script>
    
    <style lang="stylus">
    .detail-swipers
      width 100%
      height 2.6rem
      background-color #00f
      overflow hidden
      position relative
      .van-swipe
        height 100%
        .banner-img
          width 100%
          height 100%
      .indicator-tip
        position absolute
        bottom 20px
        right 0
        width 50px
        height 30px
        background-color rgba(0, 0, 0, 0.5)
        border-radius 15px 0 0 15px
        color #fff
        display flex
        justify-content center
        align-items center
    </style>
    
  • 详情页导入轮播图

    <!-- src/views/detail/index.vue -->
    <template>
      <div class="box">
        <header class="header">detail header</header>
        <div class="content">
          <Banner :list="banners" />
        </div>
      </div>
    </template>
    
    <script lang="ts">
    import { defineComponent } from 'vue';
    import { getProDetail } from '@/api/detail'
    import { Banner } from './components'
    interface IData {
      proid: string
      banners: string[]
      brand: string
      category: string
      desc: string
      discount: number
      img1: string
      isrecommend: number
      issale: number
      isseckill: number
      originprice: number
      proname: string
      sales: number
      stock: number
    }
    export default defineComponent({
      components: {
        Banner
      },
      data (): IData {
        return {
          proid: '',
          banners: [],
          brand: '',
          category: '',
          desc: '',
          discount: 0,
          img1: '',
          isrecommend: 0,
          issale: 0,
          isseckill: 0,
          originprice: 0,
          proname: '',
          sales: 0,
          stock: 0
        }
      },
      mounted () {
        console.log(this.$route.params)
        // this.proid = String(this.$route.params.proid)
        this.proid = this.$route.params.proid as string
        getProDetail(this.proid).then(res => {
          console.log(res.data.data)
          this.banners = res.data.data.banners[0].split(',')
          this.brand = res.data.data.brand
          this.category = res.data.data.category
          this.desc = res.data.data.desc
          this.discount = res.data.data.discount
          this.img1 = res.data.data.img1
          this.isrecommend = res.data.data.isrecommend
          this.issale = res.data.data.issale
          this.isseckill = res.data.data.isseckill
          this.originprice = res.data.data.originprice
          this.proname = res.data.data.proname
          this.sales = res.data.data.sales
          this.stock = res.data.data.stock
        })
      }
    })
    </script>
    
    <style lang="stylus"></style>
    
5.8.8 点击播放视频

获取视频时长: dom.duration

获取当前视频播放进度: dom.currentTime

修改当前视频播放进度: dom.currentTime = num

播放视频: dom.play()

暂停视频: dom.pause()

调整音量:dom.volume = 0-1

全屏: dom.requestFullScreen() ---- 需要设置video的宽和高也为全屏

静音: dom.muted = true

5.8.9 构建产品的详细信息
<!-- src/views/detal/components/ProInfo.vue -->
<template>
  <div>
    产品信息
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';

export default defineComponent({})
</script>

<style lang='stylus'>
  
</style>
// src/views/detail/components/index.ts
export { default as Banner } from './Banner.vue'
export { default as ProInfo } from './ProInfo.vue'
<!-- src/views/detail/index.vue -->
<template>
  <div class="box">
    <header class="header">detail header</header>
    <div class="content">
      <Banner :list="banners" />
      <ProInfo 
        :brand="brand"
        :category="category"
        :desc="desc"
        :discount="discount"
        :originprice="originprice"
        :proname="proname"
        :sales="sales"
        :stock="stock"
      />
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { getProDetail } from '@/api/detail'
import { Banner, ProInfo } from './components'
interface IData {
  proid: string
  banners: string[]
  brand: string
  category: string
  desc: string
  discount: number
  img1: string
  isrecommend: number
  issale: number
  isseckill: number
  originprice: number
  proname: string
  sales: number
  stock: number
}
export default defineComponent({
  components: {
    Banner, ProInfo
  },
  data (): IData {
    return {
      proid: '',
      banners: [],
      brand: '',
      category: '',
      desc: '',
      discount: 0,
      img1: '',
      isrecommend: 0,
      issale: 0,
      isseckill: 0,
      originprice: 0,
      proname: '',
      sales: 0,
      stock: 0
    }
  },
  mounted () {
    console.log(this.$route.params)
    // this.proid = String(this.$route.params.proid)
    this.proid = this.$route.params.proid as string
    getProDetail(this.proid).then(res => {
      console.log(res.data.data)
      this.banners = res.data.data.banners[0].split(',')
      this.brand = res.data.data.brand
      this.category = res.data.data.category
      this.desc = res.data.data.desc
      this.discount = res.data.data.discount
      this.img1 = res.data.data.img1
      this.isrecommend = res.data.data.isrecommend
      this.issale = res.data.data.issale
      this.isseckill = res.data.data.isseckill
      this.originprice = res.data.data.originprice
      this.proname = res.data.data.proname
      this.sales = res.data.data.sales
      this.stock = res.data.data.stock
    })
  }
})
</script>

<style lang="stylus"></style>
<!-- src/views/detal/components/ProInfo.vue -->
<template>
  <div class="proInfo">
    <div class="priceBox">
      <span>¥{{ originprice }}</span>
      <span>销量:{{ sales }}</span>
    </div>
    <div class="proName">
      <van-tag type="danger">{{ brand }}</van-tag>
      <van-tag type="primary">{{ category }}</van-tag>
      <h3>{{ proname }}</h3>
      <p>{{ desc }}</p>
    </div>
  </div>
  <div class="address">
    <van-field
      v-model="fieldValue"
      is-link
      readonly
      label="送至"
      placeholder="请选择所在地区"
      @click="show = true"
    />
    <van-popup v-model:show="show" round position="bottom">
      <van-cascader
        v-model="cascaderValue"
        title="请选择所在地区"
        :options="options"
        @close="show = false"
        @finish="onFinish"
      />
    </van-popup>
  </div>
</template>
<script lang='ts'>
import type { CascaderOption } from 'vant';
import { defineComponent } from 'vue';

export default defineComponent({
  props: {
    brand: String,
    category: String,
    desc: String,
    discount: Number,
    originprice: Number,
    proname: String,
    sales: Number,
    stock: Number
  },
  data () {
    return {
      fieldValue: '',
      show: false,
      cascaderValue: '',
      options: [
        {
          text: '浙江省',
          value: '330000',
          children: [{ text: '杭州市', value: '330100' }],
        },
        {
          text: '江苏省',
          value: '320000',
          children: [{ text: '南京市', value: '320100' }],
        },
      ],

    }
  },
  methods: {
    onFinish ({ selectedOptions }: { selectedOptions: CascaderOption[]}) {
      this.show = false;
      this.fieldValue = selectedOptions.map((option) => option.text).join('/');
    }
  }
})
</script>

<style lang='stylus'>
.proInfo {
  background-color: #fff;
  padding: 15px;
  border-bottom-right-radius: 16px;
  border-bottom-left-radius: 16px;
  .priceBox {
    span {
      line-height: 32px;
      &:nth-child(1) {
        font-size: 24px;
        color: #f66;
      }
      &:nth-child(2) {
        float: right;
      }
    }
  }
  .proName {
    // font-weight: bold;
    font-size: 0.14rem;
    .van-tag {
      margin-right: 5px;
    }
    p {
      margin-top: 15px;
    }
  }
}
.address {
  margin-top: 10px;
}
</style>
5.8.11 猜你喜欢

拷贝了首页的产品列表组件,同时要保证点击推荐列表修改详情页的数据(通过子组件给父组件传值)

<!-- src/views/detail/components/Pro.vue -->
<template>
  <ul class="detail-proList">
    <li class="proItem" v-for="item of proList" :key="item.proid" @click="toDetail(item.proid)">
      <div class="itemImage">
        <van-image :src="item.img1"></van-image>
      </div>
      <div class="itemInfo">
        <div class="title van-multi-ellipsis--l2">{{ item.proname }}</div>
        <div class="price">¥{{ item.originprice }}</div>
        <div class="other">
          <van-tag type="danger">{{ item.category }}</van-tag>
        </div>
      </div>
    </li>
  </ul>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import type { PropType } from 'vue'
import type { IPro } from '../detail'

export default defineComponent({
  props: {
    proList: Array as PropType<IPro[]>
  },
  methods: {
    toDetail (proid: string) {
      this.$router.push(`/detail/${proid}`)
      // this.$router.push('/detail/' + proid)
      // this.$router.push({ path: '/detail/' + proid })
      // this.$router.push({ name: 'detail', params: { proid: proid } })
    }
  }
})
</script>

<style lang='stylus'>
.detail-proList
  display flex
  flex-wrap wrap
  .proItem
    width 28%
    margin 8px 2%
    // min-height 2.6rem
    background-color #fff
    border-radius 10px
    overflow hidden
    .itemImage
      width 100%
      height 1.2rem
      .van-image
        width 100%
        height 100%
        display block
    .itemInfo
      padding 10px
      .price 
        color #f66
        margin-top 5px
      .other
        margin-top 5px
</style>
// src/views/detail/components/index.ts
export { default as Banner } from './Banner.vue'
export { default as ProInfo } from './ProInfo.vue'
export { default as Pro } from './Pro.vue'
<!-- src/views/detail/index.vue -->
<template>
  <div class="box">
    <header class="header">detail header</header>
    <div class="content">
      <Banner :list="banners" />
      <ProInfo 
        :brand="brand"
        :category="category"
        :desc="desc"
        :discount="discount"
        :originprice="originprice"
        :proname="proname"
        :sales="sales"
        :stock="stock"
      />
      <div class="recommendBox">
        <h4 >猜你喜欢</h4>
        <Pro :proList="recommendList" />
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { getProDetail, getRecommendList } from '@/api/detail'
import { Banner, ProInfo, Pro } from './components'
import type { IPro } from './detail';
interface IData {
  proid: string
  banners: string[]
  brand: string
  category: string
  desc: string
  discount: number
  img1: string
  isrecommend: number
  issale: number
  isseckill: number
  originprice: number
  proname: string
  sales: number
  stock: number
  recommendList: IPro[]
}
export default defineComponent({
  components: {
    Banner, ProInfo, Pro
  },
  data (): IData {
    return {
      proid: '',
      banners: [],
      brand: '',
      category: '',
      desc: '',
      discount: 0,
      img1: '',
      isrecommend: 0,
      issale: 0,
      isseckill: 0,
      originprice: 0,
      proname: '',
      sales: 0,
      stock: 0,
      recommendList: []
    }
  },
  mounted () {
    console.log(this.$route.params)
    // this.proid = String(this.$route.params.proid)
    this.proid = this.$route.params.proid as string
    getProDetail(this.proid).then(res => {
      // console.log(result)
      const result = res.data.data
      this.banners = result.banners[0].split(',')
      this.brand = result.brand
      this.category = result.category
      this.desc = result.desc
      this.discount = result.discount
      this.img1 = result.img1
      this.isrecommend = result.isrecommend
      this.issale = result.issale
      this.isseckill = result.isseckill
      this.originprice = result.originprice
      this.proname = result.proname
      this.sales = result.sales
      this.stock = result.stock
    })

    getRecommendList().then(res => {
      this.recommendList = res.data.data
    })
  }
})
</script>

<style lang="stylus">
.recommendBox
  width 100%
  min-height 300px
  border-radius 16px
  background-color #fff
  margin-top 15px
  h4
    padding 15px
</style>

直接在父组件监听路由的变化实现

<!-- src/views/detail/index.vue -->
<template>
  <div class="box">
    <header class="header">detail header</header>
    <div class="content">
      <Banner :list="banners" />
      <ProInfo 
        :brand="brand"
        :category="category"
        :desc="desc"
        :discount="discount"
        :originprice="originprice"
        :proname="proname"
        :sales="sales"
        :stock="stock"
      />
      <div class="recommendBox">
        <h4 >猜你喜欢</h4>
        <Pro :proList="recommendList" />
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { getProDetail, getRecommendList } from '@/api/detail'
import { Banner, ProInfo, Pro } from './components'
import type { IPro } from './detail';
interface IData {
  proid: string
  banners: string[]
  brand: string
  category: string
  desc: string
  discount: number
  img1: string
  isrecommend: number
  issale: number
  isseckill: number
  originprice: number
  proname: string
  sales: number
  stock: number
  recommendList: IPro[]
}
export default defineComponent({
  components: {
    Banner, ProInfo, Pro
  },
  data (): IData {
    return {
      proid: '',
      banners: [],
      brand: '',
      category: '',
      desc: '',
      discount: 0,
      img1: '',
      isrecommend: 0,
      issale: 0,
      isseckill: 0,
      originprice: 0,
      proname: '',
      sales: 0,
      stock: 0,
      recommendList: []
    }
  },
  mounted () {
    console.log(this.$route.params)
    // this.proid = String(this.$route.params.proid)
    this.proid = this.$route.params.proid as string
    getProDetail(this.proid).then(res => {
      // console.log(result)
      const result = res.data.data
      this.banners = result.banners[0].split(',')
      this.brand = result.brand
      this.category = result.category
      this.desc = result.desc
      this.discount = result.discount
      this.img1 = result.img1
      this.isrecommend = result.isrecommend
      this.issale = result.issale
      this.isseckill = result.isseckill
      this.originprice = result.originprice
      this.proname = result.proname
      this.sales = result.sales
      this.stock = result.stock
    })

    getRecommendList().then(res => {
      this.recommendList = res.data.data
    })
  },
  watch: { // 点击猜你喜欢的商品后 路由变化 重新渲染数据
    $route (newVal) {
      this.proid = newVal.params.proid
      getProDetail(this.proid).then(res => {
        // console.log(result)
        const result = res.data.data
        this.banners = result.banners[0].split(',')
        this.brand = result.brand
        this.category = result.category
        this.desc = result.desc
        this.discount = result.discount
        this.img1 = result.img1
        this.isrecommend = result.isrecommend
        this.issale = result.issale
        this.isseckill = result.isseckill
        this.originprice = result.originprice
        this.proname = result.proname
        this.sales = result.sales
        this.stock = result.stock
      })
    }
  }
})
</script>

<style lang="stylus">
.recommendBox
  width 100%
  min-height 300px
  border-radius 16px
  background-color #fff
  margin-top 15px
  h4
    padding 15px
</style>

一旦轮播图滑动,切换商品后 轮播图组件的属性 current 没有重置

<!-- src/views/detail/components/Banner.vue -->
<template>
  <div class="detail-swipers">
    <van-swipe @change="changeIndex" :initial-swipe="current">
      <van-swipe-item v-for="(item, index) of list" :key="index" @click="previewImage(index)">
        <van-image
          fit="fill"
          class="banner-img"
          :src="item"
        />
      </van-swipe-item>
    </van-swipe>
    <div class="indicator-tip">
      {{ current + 1 }} / {{ total }}
    </div>
  </div>
</template>

<script lang='ts'>
import { defineComponent } from 'vue';
import type { PropType} from 'vue'
import { showImagePreview } from 'vant'
export default defineComponent({
  props: {
    list: Array as PropType<string[]> // ts环境
  },
  data () {
    return {
      current: 0
    }
  },
  computed: {
    total () {
      return this.list!.length
    }
  },
  watch: {
    $route () { // 因为组件一旦被创建,后续组件的更新 不会重置状态
      this.current = 0
    }
  },
  methods: {
    changeIndex (index: number) {
      this.current = index
    },
    previewImage (index: number) {
      showImagePreview({
        images: this.list!,
        startPosition: index,
        onChange: (idx: number) => { // 保证大图预览完毕 序号要一致 给 轮播图设置 initial-swipe 属性
          console.log('666', idx)
          this.current = idx
        }
      })
    }
  }
})
</script>

<style lang="stylus">
.detail-swipers
  width 100%
  height 2.6rem
  background-color #00f
  overflow hidden
  position relative
  .van-swipe
    height 100%
    .banner-img
      width 100%
      height 100%
  .indicator-tip
    position absolute
    bottom 20px
    right 0
    width 50px
    height 30px
    background-color rgba(0, 0, 0, 0.5)
    border-radius 15px 0 0 15px
    color #fff
    display flex
    justify-content center
    align-items center
</style>
5.8.12 详情页面底部
<!-- src/views/detail/index.vue -->
<template>
  <div class="box">
    <header class="header">detail header</header>
    <div class="content">
      <Banner :list="banners" />
      <ProInfo 
        :brand="brand"
        :category="category"
        :desc="desc"
        :discount="discount"
        :originprice="originprice"
        :proname="proname"
        :sales="sales"
        :stock="stock"
      />
      <div class="recommendBox">
        <h4 >猜你喜欢</h4>
        <Pro :proList="recommendList" />
      </div>

      <van-action-bar>
        <van-action-bar-icon icon="chat-o" text="客服" color="#ee0a24" />
        <van-action-bar-icon icon="cart-o" text="购物车" />
        <van-action-bar-icon icon="star" text="已收藏" color="#ff5000" />
        <van-action-bar-button type="warning" v-if="issale === 1" text="加入购物车" />
        <van-action-bar-button type="danger" disabled v-else text="商品已下架" />
      </van-action-bar>
      <div style="height: 60px"></div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { getProDetail, getRecommendList } from '@/api/detail'
import { Banner, ProInfo, Pro } from './components'
import type { IPro } from './detail';
interface IData {
  proid: string
  banners: string[]
  brand: string
  category: string
  desc: string
  discount: number
  img1: string
  isrecommend: number
  issale: number
  isseckill: number
  originprice: number
  proname: string
  sales: number
  stock: number
  recommendList: IPro[]
}
export default defineComponent({
  components: {
    Banner, ProInfo, Pro
  },
  data (): IData {
    return {
      proid: '',
      banners: [],
      brand: '',
      category: '',
      desc: '',
      discount: 0,
      img1: '',
      isrecommend: 0,
      issale: 0,
      isseckill: 0,
      originprice: 0,
      proname: '',
      sales: 0,
      stock: 0,
      recommendList: []
    }
  },
  mounted () {
    console.log(this.$route.params)
    // this.proid = String(this.$route.params.proid)
    this.proid = this.$route.params.proid as string
    getProDetail(this.proid).then(res => {
      // console.log(result)
      const result = res.data.data
      this.banners = result.banners[0].split(',')
      this.brand = result.brand
      this.category = result.category
      this.desc = result.desc
      this.discount = result.discount
      this.img1 = result.img1
      this.isrecommend = result.isrecommend
      this.issale = result.issale
      this.isseckill = result.isseckill
      this.originprice = result.originprice
      this.proname = result.proname
      this.sales = result.sales
      this.stock = result.stock
    })

    getRecommendList().then(res => {
      this.recommendList = res.data.data
    })
  },
  watch: { // 点击猜你喜欢的商品后 路由变化 重新渲染数据
    $route (newVal) {
      this.proid = newVal.params.proid
      getProDetail(this.proid).then(res => {
        // console.log(result)
        const result = res.data.data
        this.banners = result.banners[0].split(',')
        this.brand = result.brand
        this.category = result.category
        this.desc = result.desc
        this.discount = result.discount
        this.img1 = result.img1
        this.isrecommend = result.isrecommend
        this.issale = result.issale
        this.isseckill = result.isseckill
        this.originprice = result.originprice
        this.proname = result.proname
        this.sales = result.sales
        this.stock = result.stock
      })
    }
  }
})
</script>

<style lang="stylus">
.recommendBox
  width 100%
  min-height 300px
  border-radius 16px
  background-color #fff
  margin-top 15px
  h4
    padding 15px
</style>
5.8.13 详情页面头部
<!-- src/views/detail/index.vue -->
<template>
  <div class="box">
    <header class="header detail-header" >
      <div class="header1" :class="op">
        <!-- <van-nav-bar left-arrow >
          <template #right>
            <van-icon name="search" size="18" />
          </template>
        </van-nav-bar> -->
        <ul>
          <li>
            <van-icon name="arrow-left" size="18" />
          </li>
          <li></li>
          <li>
            <van-icon name="weapp-nav" size="18" />
          </li>
        </ul>
      </div>
      <div class="header2" :class="op">
        <van-nav-bar left-arrow>
          <template #right>
            <van-icon name="weapp-nav" size="18" />
          </template>
          <template #title>
            <ul class="header-color">
              <li :class="scrollTop < recommendTop - 44 ? 'active' : ''" @click="backTop(0)">商品</li>
              <li :class="scrollTop >= recommendTop - 44 ? 'active' : ''" @click="backTop(recommendTop - 44)">推荐</li>
            </ul>
          </template>
        </van-nav-bar>
      </div>
    </header>
    <div class="content" @scroll="onScroll" ref="content">
      <Banner :list="banners" />
      <ProInfo 
        :brand="brand"
        :category="category"
        :desc="desc"
        :discount="discount"
        :originprice="originprice"
        :proname="proname"
        :sales="sales"
        :stock="stock"
      />
      <div class="recommendBox" ref="recommend">
        <h4 >猜你喜欢</h4>
        <Pro :proList="recommendList" />
      </div>

      <van-action-bar>
        <van-action-bar-icon icon="chat-o" text="客服" color="#ee0a24" />
        <van-action-bar-icon icon="cart-o" text="购物车" />
        <van-action-bar-icon icon="star" text="已收藏" color="#ff5000" />
        <van-action-bar-button type="warning" v-if="issale === 1" text="加入购物车" />
        <van-action-bar-button type="danger" disabled v-else text="商品已下架" />
      </van-action-bar>
      <div style="height: 60px"></div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { getProDetail, getRecommendList } from '@/api/detail'
import { Banner, ProInfo, Pro } from './components'
import type { IPro } from './detail';
interface IData {
  proid: string
  banners: string[]
  brand: string
  category: string
  desc: string
  discount: number
  img1: string
  isrecommend: number
  issale: number
  isseckill: number
  originprice: number
  proname: string
  sales: number
  stock: number
  recommendList: IPro[]
  op: string,
  recommendTop: number
  scrollTop: number
}
export default defineComponent({
  components: {
    Banner, ProInfo, Pro
  },
  data (): IData {
    return {
      proid: '',
      banners: [],
      brand: '',
      category: '',
      desc: '',
      discount: 0,
      img1: '',
      isrecommend: 0,
      issale: 0,
      isseckill: 0,
      originprice: 0,
      proname: '',
      sales: 0,
      stock: 0,
      recommendList: [],
      op: 'op0',
      recommendTop: 0,
      scrollTop: 0
    }
  },
  mounted () {
    console.log(this.$route.params)
    // this.proid = String(this.$route.params.proid)
    this.proid = this.$route.params.proid as string
    getProDetail(this.proid).then(res => {
      // console.log(result)
      const result = res.data.data
      this.banners = result.banners[0].split(',')
      this.brand = result.brand
      this.category = result.category
      this.desc = result.desc
      this.discount = result.discount
      this.img1 = result.img1
      this.isrecommend = result.isrecommend
      this.issale = result.issale
      this.isseckill = result.isseckill
      this.originprice = result.originprice
      this.proname = result.proname
      this.sales = result.sales
      this.stock = result.stock
      
      // 动态计算某个元素距页面顶部的距离
      this.$nextTick(() => {
        console.log((this.$refs.recommend as HTMLDivElement).offsetTop)
        this.recommendTop = (this.$refs.recommend as HTMLDivElement).offsetTop
      })
    })

    getRecommendList().then(res => {
      this.recommendList = res.data.data
    })
  },
  watch: { // 点击猜你喜欢的商品后 路由变化 重新渲染数据
    $route (newVal) {
      this.proid = newVal.params.proid
      getProDetail(this.proid).then(res => {
        // console.log(result)
        const result = res.data.data
        this.banners = result.banners[0].split(',')
        this.brand = result.brand
        this.category = result.category
        this.desc = result.desc
        this.discount = result.discount
        this.img1 = result.img1
        this.isrecommend = result.isrecommend
        this.issale = result.issale
        this.isseckill = result.isseckill
        this.originprice = result.originprice
        this.proname = result.proname
        this.sales = result.sales
        this.stock = result.stock

        // 动态计算某个元素距页面顶部的距离
        this.$nextTick(() => {
          console.log((this.$refs.recommend as HTMLDivElement).offsetTop)
          this.recommendTop = (this.$refs.recommend as HTMLDivElement).offsetTop
        })
      })
    }
  },
  methods: {
    onScroll () {
      // 核心算法 计算 滚动比例
      console.log((this.$refs.content as HTMLDivElement).scrollTop)
      this.scrollTop = (this.$refs.content as HTMLDivElement).scrollTop
      const m = Math.floor((this.$refs.content as HTMLDivElement).scrollTop / 10)
      if (m > 10) {
        this.op = 'op10'
      } else {
        this.op = 'op' + m
      }
    },
    backTop (num: number) {
      (this.$refs.content as HTMLDivElement).scrollTop = num
    }
  }
})
</script>

<style lang="stylus">
// 层级修改背景
.container
  .box
    .header
      background-color #f66
      &.detail-header
        background-color transparent
.detail-header
  position fixed
  top 0
  width 100%
  z-index 999
  // opacity 0
  // background-color #fff
  .header1 
    position fixed
    width 100%
    top 0
    height 0.44rem
    opacity 1
    &.op0 
      opacity 1
    &.op1
      opacity 0.9
    &.op2
      opacity 0.8
    &.op3
      opacity 0.7
    &.op4
      opacity 0.6
    &.op5
      opacity 0.5
    &.op6
      opacity 0.4
    &.op7
      opacity 0.3
    &.op8
      opacity 0.2
    &.op9
      opacity 0.1
    &.op10
      opacity 0
    ul
      display flex
      height 100%
      // justify-content space-between
      li
        // flex 1
        height 100%
        display flex
        justify-content center
        align-items center
        color #000
        &:nth-child(1),   &:nth-child(3){
          width 50px
        }
        &:nth-child(2) {
          flex 1
        }
        
  .header2
    position fixed
    width 100%
    top 0
    color #000
    &.op0 
      opacity 0
    &.op1
      opacity 0.1
    &.op2
      opacity 0.2
    &.op3
      opacity 0.3
    &.op4
      opacity 0.4
    &.op5
      opacity 0.5
    &.op6
      opacity 0.6
    &.op7
      opacity 0.7
    &.op8
      opacity 0.8
    &.op9
      opacity 0.9
    &.op10
      opacity 1
    .header-color
      color #000  
      display flex  
      li
        flex 1
        &:nth-child(1) {
          margin-right 10px
        }
        &:nth-child(2) {
          margin-left 10px
        }
        &.active 
          border-bottom 3px solid #f66
.recommendBox
  width 100%
  min-height 300px
  border-radius 16px
  background-color #fff
  margin-top 15px
  h4
    padding 15px
</style>
5.8.14 收藏功能 - 作业

本操作属于纯前端操作

1.进入详情页,需要先判断该商品是否被收藏过,如果收藏过,显示已收藏,如果未被收藏过,显示收藏

2.如果当前商品是收藏的,点击要取消收藏

3.如果当前商品是未收藏的,点击收藏

4.数据可以存到本地,localStorage 只能保存 字符串

5.收藏夹内只保存 产品id,以数组的形式存在

6.检测到路由变化 ,执行判断是否收藏

<!-- src/views/detail/IndexView.vue -->
<template>
  
    <div class="myHeader">
      <Transition name="fade">
        <header class="header1" v-show="scrollTop < 300">
          <ul>
            <li class="left" @click="$router.back()">
              <van-icon name="arrow-left" />
            </li>
            <li class="middle"></li>
            <li class="right">
              <van-popover  placement="bottom-end" v-model:show="showPopover" :actions="actions" @select="onSelect" theme="dark">
                <template #reference>
                  <van-icon name="ellipsis" />
                </template>
              </van-popover>
            </li>
          </ul>
          
         
        </header>
      </Transition>
      <Transition  name="fade">
        <header class="header2" v-show="scrollTop > 300">
          <ul>
            <li class="left" @click="$router.back()">
              <van-icon name="arrow-left" />
            </li>
            <li class="middle">
              <span>详情</span>
              <span>推荐</span>
            </li>
            <li class="right">
              <van-popover  placement="bottom-end" v-model:show="showPopover" :actions="actions" @select="onSelect" theme="dark">
                <template #reference>
                  <van-icon name="ellipsis" />
                </template>
              </van-popover>
            </li>
          </ul>
        </header>
      </Transition>
    </div>
  <div class="content" @scroll="scroll">
    <BannerComponent :list="banners" :video="video"></BannerComponent>
    <ProInfoComponent :proname="proname" :discount="discount" :originprice="originprice" :sales="sales" :brand="brand" :category="category"></ProInfoComponent>
    <ProComponent :list="recommendList"></ProComponent>
    
    <van-action-bar>
      <van-action-bar-icon icon="chat-o" text="客服" />
      <van-action-bar-icon icon="cart-o" text="购物车" />
      <van-action-bar-icon v-if="isStar" icon="star" color="#f66" text="已收藏" @click="changeStar"/>
      <van-action-bar-icon v-else icon="star-o" text="收藏"  @click="changeStar"/>
      <van-action-bar-button type="danger" v-if="issale" text="加入购物车" />
      <van-action-bar-button type="danger" :disabled="issale === 0" v-else text="商品已下架" />
    </van-action-bar>

    <van-share-sheet
      v-model:show="showShare"
      title="立即分享给好友"
      :options="options"
      @select="onShareSelect"
    />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { getDetailData, getDetailRecommendData } from '@/api/detail'
  import BannerComponent from './components/BannerComponent.vue'
  import ProInfoComponent from './components/ProInfoComponent.vue'
  import ProComponent from './components/ProComponent.vue'
  import type { IPro } from '../home/home';
  interface IData {
    proid: string
    banners: string[]
    proname: string // 名称
    originprice: number // 原价
    discount: number // 折扣
    brand: string // 品牌
    category: string // 分类
    sales: number // 销量
    issale: number // 1表示正在售卖,0表示已下架
    video: string
    recommendList: IPro[]
    scrollTop: number,
    showPopover: boolean
    actions: Array<{ text: string, icon: string }>
    options: Array<{ name: string, icon: string }>,
    showShare: boolean
    isStar: boolean // ++++++++
  }
  export default defineComponent({
    components: {
      BannerComponent,
      ProInfoComponent,
      ProComponent
    },
    data (): IData {
      return {
        proid: '',
        banners: [],
        proname: '',
        originprice: 0,
        discount: 0,
        brand: '',
        category: '',
        sales: 0,
        issale: 1,
        video: '',
        recommendList: [],
        scrollTop: 0,
        showPopover: false,
        actions: [
          { text: '首页', icon: 'wap-home-o' },
          { text: '我的', icon: 'manager-o' },
          { text: '分享', icon: 'share-o' },
        ],
        options: [
          { name: '微信', icon: 'wechat' },
          { name: '微博', icon: 'weibo' },
          { name: '复制链接', icon: 'link' },
          { name: '分享海报', icon: 'poster' },
          { name: '二维码', icon: 'qrcode' }
        ],
        showShare: false,
        isStar: true // ++++++++
      }
    },
    mounted () {
      console.log(this.$route.params.proid)
      this.proid = (this.$route.params.proid as string)
      this.getData(this.proid)

      getDetailRecommendData().then(res => {
        this.recommendList = res.data.data
      })

      this.getStarFlag(this.proid) // ++++++++
    },
    methods: {
      changeStar () { // ++++++++
        if (this.isStar) {
          const starArr = JSON.parse(localStorage.getItem('stars')!)
          const index = starArr.findIndex((item: string) => item === this.proid)
          starArr.splice(index, 1)
          localStorage.setItem('stars', JSON.stringify(starArr))
          this.isStar = false
        } else {
          const arrStr: any = localStorage.getItem('stars') || '[]'
          const starArr = JSON.parse(arrStr)
          starArr.push(this.proid)
          localStorage.setItem('stars', JSON.stringify(starArr))
          this.isStar = true
        }
      },
      getStarFlag (proid: string) { // ++++++++
        const starArr = JSON.parse(localStorage.getItem('stars')!)
        if (starArr) {
          const index = starArr.findIndex((item: string) => item === proid)
          if (index !== -1) {
            this.isStar = true
          } else {
            this.isStar = false
          }
        } else {
          this.isStar = false
        }
      },
      getData (proid:string) {
        getDetailData(proid).then(res => {
          console.log(res.data.data)
          const result = res.data.data
          this.banners = result.banners[0].split(',')
          this.proname = result.proname
          this.originprice = result.originprice
          this.discount = result.discount
          this.brand = result.brand
          this.category = result.category
          this.sales = result.sales
          this.issale = result.issale
          this.video = 'https://vod.300hu.com/4c1f7a6atransbjngwcloud1oss/542359bf391145770504425473/v.f20.mp4'
        })
      },
      scroll (event: Event) {
        this.scrollTop = (event.target as HTMLDivElement).scrollTop
      },
      onSelect (action: { text: string, icon: string }) {
        console.log(action)
        switch (action.text) {
          case '首页':
            this.$router.push('/home')
            break;
          case '我的':
            this.$router.push('/user')
            break;
          case '分享':
            this.showShare = true
            break;
        }
      },
      onShareSelect (option: { name: string, icon: string }) {
        console.log(option)
      }
    },
    watch: {
      $route (val) {
        console.log(val)
        this.getData(val.params.proid)
        this.getStarFlag(val.params.proid) // ++++++++
      }
    }
  })
</script>

<style lang="scss">
  .fade-enter-from, .fade-leave-to {
    opacity: 0;
  }
  .fade-enter-active {
    transition: all 0.5s;
  }
  .fade-leave-active {
    transition: all 0s;
  }

  .fade-enter-to, .fade-leave-from {
    opacity: 1;
  }
  .myHeader {
    user-select: none;
    position: fixed;
    top: 0;
    width: 100%;
    z-index: 999;
    .header1 {
      height: 0.44rem;
      padding: 6px 15px;
      box-sizing: border-box;
      ul {
        width: 100%;
        height: 100%;
        display: flex;
        li {
          &:nth-child(1), &:nth-child(3) {
            font-size: 32px;
            width: 44px;
          }
          &:nth-child(2) {
            flex: 1;
          }
        }
      }
      
    }
    .header2 {
      height: 0.44rem;
      padding: 6px 15px;
      box-sizing: border-box;
      background-color: #fff;
      ul {
        width: 100%;
        height: 100%;
        display: flex;
        li {
          &:nth-child(1), &:nth-child(3) {
            font-size: 32px;
            width: 44px;
          }
          &:nth-child(2) {
            flex: 1;
            display: flex;
            span {
              flex: 1;
              display: flex;
              justify-content: center;
              align-items: center;
            }
          }
        }
      }
      
    }
  }
</style>
5.8.15 分析接下来操作

加入购物车(是谁,加了几件,加了哪个商品进入购物车)

是谁

登录

注册

5.9 注册功能

5.9.1 注册思路

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vRZak2KJ-1673602369091)(assets/image-20220923073954061.png)]

5.9.2 嵌套路由

一些应用程序的 UI 由多层嵌套的组件组成。在这种情况下,URL 的片段通常对应于特定的嵌套组件结构,例如:

/user/johnny/profile                     /user/johnny/posts
+------------------+                  +-----------------+
| User             |                  | User            |
| +--------------+ |                  | +-------------+ |
| | Profile      | |  +------------>  | | Posts       | |
| |              | |                  | |             | |
| +--------------+ |                  | +-------------+ |
+------------------+                  +-----------------+

通过 Vue Router,你可以使用嵌套路由配置来表达这种关系。

/register 注册路由

/register/index 注册第一步

/register/sms 注册第二步

/register/pwd 注册第三步

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UWfKNJk3-1673602369092)(assets/image-20220927101429498.png)]

<!-- src/views/register/components/CheckTel.vue -->
<template>
  <div>校验手机号</div>
</template>
<!-- src/views/register/components/SendTelCode.vue -->
<template>
  <div>发送短信验证码</div>
</template>
<!-- src/views/register/components/SetPassword.vue -->
<template>
  <div>设置密码</div>
</template>
// src/views/register/components/index.ts
export { default as CheckTel } from './CheckTel.vue'
export { default as SendTelCode } from './SendTelCode.vue'
export { default as SetPassword } from './SetPassword.vue'
// src/router/index.ts
import  { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, Router } from 'vue-router'
// 直接引入组件,不管你用不用这个页面,我都引入了 --- 静态导入
import Home from '@/views/home/index.vue'
import Footer from '@/components/Footer.vue'
import { CheckTel, SendTelCode, SetPassword } from '@/views/register/components'
// import Kind from '@/views/kind/index.vue'
// import Cart from '@/views/cart/index.vue'
// import User from '@/views/user/index.vue'
// 使用路由懒加载 - 访问该路由时才加载该组件    ---  动态导入
const Kind = () => import('@/views/kind/index.vue')
const Cart = () => import('@/views/cart/index.vue')
const User = () => import('@/views/user/index.vue')
const Detail = () => import('@/views/detail/index.vue')
const Vdo = () => import('@/views/video/index.vue')

const Register = () => import('@/views/register/index.vue')



const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home',
    name: 'home',
    // component: Home
    components: {
      default: Home,
      footer: Footer
    }
  },
  {
    path: '/kind',
    name: 'kind',
    // component: Kind
    components: {
      default: Kind,
      footer: Footer
    }
  },
  {
    path: '/cart',
    name: 'cart',
    // component: Cart
    components: {
      default: Cart,
      footer: Footer
    }
  },
  {
    path: '/user',
    name: 'user',
    // component: User
    components: {
      default: User,
      footer: Footer
    }
  },
  {
    path: '/detail/:proid',
    name: 'detail',
    // component: Detail
    components: { // 与 component: Detail 等价
      default: Detail
    }
  },
  {
    path: '/vdo',
    name: 'vdo',
    components: {
      default: Vdo
    }
  },
  {
    path: '/register',
    name: 'register',
    redirect: '/register/index',
    component: Register,
    children: [
      {
        path: 'index', //  /register/index
        component: CheckTel
      },
      {
        path: 'sms', //  /register/sms
        component: SendTelCode
      },
      {
        path: 'pwd', //  /register/pwd
        component: SetPassword
      }
    ]
  }
]

const router: Router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
<!-- src/views/register/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="注册"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <RouterView />
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';

export default defineComponent({})
</script>

<style lang='stylus'>
  
</style>
5.9.3 注册第一步页面
<!-- src/views/register/components/CheckTel.vue -->
<template>
  <div class="register-form">
    <div class="form-input">
      <van-field v-model="tel" placeholder="请输入手机号" clearable/>
    </div>
    <div class="form-btn">
      <van-button color="linear-gradient(to right, #ee0a24, #ff6034)" block round>下一步</van-button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
  data () {
    return {
      tel: ''
    }
  }
})
</script>

<style lang="stylus">
.register-form
  width calc(100% - 50px)
  // min-height 300px
  // background-color #00f
  margin 10px 25px 10px 25px
  padding 10px 0
  .form-input
    height 50px
    // background-color #0f0
    display flex
    align-items center
    border-bottom 1px solid #efefef
  .form-btn
    margin-top 59px
    .van-button
      height 50px
      font-size 16px
      font-weight bold
      box-shadow: 0 1px 10px 0 rgb(255 62 62 / 20%);
      background-color: #efefef;
</style>
5.9.4注册第二步页面
<!-- src/views/register/components/SendTelCode.vue -->
<template>
  <div class="register-form">
    <div class="form-input">
      <van-field
        v-model="telcode"
        center
        clearable
        placeholder="请输入手机验证码"
      >
        <template #button>
          <van-button class="sms-btn" color="rgba(226,35,30,.2)" size="small" round>
            <span style="color: #e2231a">获取验证码</span>
          </van-button>
        </template>
      </van-field>
    </div>
    <div class="form-btn">
      <van-button color="linear-gradient(to right, #ee0a24, #ff6034)" block round>下一步</van-button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
  data () {
    return {
      tel: '',
      telcode: ''
    }
  }
})
</script>

<style lang="stylus">
.register-form
  width calc(100% - 50px)
  // min-height 300px
  // background-color #00f
  margin 10px 25px 10px 25px
  padding 10px 0
  .form-input
    height 50px
    // background-color #0f0
    display flex
    align-items center
    border-bottom 1px solid #efefef
    overflow hidden
    .sms-btn
      border-color rgba(226,35,30,.2)
      padding 0 20px
  .form-btn
    margin-top 59px
    .van-button
      height 50px
      font-size 16px
      font-weight bold
      box-shadow: 0 1px 10px 0 rgb(255 62 62 / 20%);
      background-color: #efefef;
</style>
5.9.5注册第三步页面
image-20220927112907862
<!-- src/views/register/components/SetPassword.vue -->
<template>
  <div class="register-form">
    <div class="form-input">
      <van-field v-model="password" :type="type ? 'password' : 'text'" placeholder="请设置6-20位登录密码" clearable @click-right-icon="type = !type" :right-icon="type ? 'closed-eye' : 'eye-o'"/>
    </div>
    <div class="form-tip">
      密码由6-20位大小写字母数字等组成
    </div>
    <div class="form-btn">
      <van-button color="linear-gradient(to right, #ee0a24, #ff6034)" block round>完成</van-button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
  data () {
    return {
      tel: '',
      password: '',
      type: true
    }
  }
})
</script>

<style lang="stylus">
.register-form
  width calc(100% - 50px)
  // min-height 300px
  // background-color #00f
  margin 10px 25px 10px 25px
  padding 10px 0
  .form-input
    height 50px
    // background-color #0f0
    display flex
    align-items center
    border-bottom 1px solid #efefef
  .form-tip
    color: #c6c6c6
    font-size 14px
    margin-top 9px
  .form-btn
    margin-top 59px
    .van-button
      height 50px
      font-size 16px
      font-weight bold
      box-shadow: 0 1px 10px 0 rgb(255 62 62 / 20%);
      background-color: #efefef;
</style>
5.9.6 封装注册登录接口
// src/api/user.ts
import request from '@/utils/request'

// 检测手机号是否被注册过
export function doCheckPhone (params: { tel: string }) {
  return request.post('/user/docheckphone', params)
}

// 发送短信验证码
export function doSendMsgCode (params: { tel: string }) {
  return request.post('/user/dosendmsgcode', params)
}

// 验证验证码
export function doCheckCode (params: { tel: string, telcode: string }) {
  return request.post('/user/docheckcode', params)
}

// 设置密码完成注册
export function doFinishRegister (params: { tel: string, password: string }) {
  return request.post('/user/dofinishregister', params)
}

// 登录
export function doLogin (params: { loginname: string, password: string }) {
  return request.post('/user/login', params)
}
5.9.7 实现注册第一步功能
<!-- src/views/register/components/CheckTel.vue -->
<template>
  <div class="register-form">
    <div class="form-input">
      <van-field v-model="tel" placeholder="请输入手机号" clearable/>
    </div>
    <div class="form-btn">
      <van-button :disabled="!flag" @click="next" color="linear-gradient(to right, #ee0a24, #ff6034)" block round>下一步</van-button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { doCheckPhone } from '@/api/user'
import { showConfirmDialog } from 'vant';
export default defineComponent({
  data () {
    return {
      tel: '18813007814'
    }
  },
  computed: {
    flag () {
      return /^1[3-9]\d{9}$/.test(this.tel)
    }
  },
  methods: {
    next () {
      doCheckPhone({ tel: this.tel }).then(res => {
        if (res.data.code === '10005') {
          showConfirmDialog({
            message:
              '该手机号已被注册,是否立即登录。',
          })
            .then(() => {
              // on confirm
              // ** 登录  注册
              this.$router.back()
            })
            .catch(() => {
              // on cancel
            })
        } else {
          // 将手机号存入本地:后面需要,跳转到发送验证码页面
          localStorage.setItem('tel', this.tel)
          // 注册第一步  注册第二步 注册第三步 push
          // 注册第一步
          // 注册第二步
          // 注册第三步 repalce
          this.$router.push('/register/sms')
        }
      })
    }
  }
})
</script>

<style lang="stylus">
.register-form
  width calc(100% - 50px)
  // min-height 300px
  // background-color #00f
  margin 10px 25px 10px 25px
  padding 10px 0
  .form-input
    height 50px
    // background-color #0f0
    display flex
    align-items center
    border-bottom 1px solid #efefef
  .form-btn
    margin-top 59px
    .van-button
      height 50px
      font-size 16px
      font-weight bold
      box-shadow: 0 1px 10px 0 rgb(255 62 62 / 20%);
      background-color: #efefef;
</style>
5.9.8 实现注册第二步功能

发送短信验证码实际上没有发送至手机,请至控制台查看验证码

<!-- src/views/register/components/SendTelCode.vue -->
<template>
  <div class="register-form">
    <div class="form-input">
      <van-field
        v-model="telcode"
        center
        clearable
        placeholder="请输入手机验证码"
      >
        <template #button>
          <van-button :disabled="btnFlag" class="sms-btn" color="rgba(226,35,30,.2)" @click="sendCode" size="small" round>
            <span style="color: #e2231a">{{ text }}</span>
          </van-button>
        </template>
      </van-field>
    </div>
    <div class="form-btn">
      <van-button :disabled="flag" @click="checkCode" color="linear-gradient(to right, #ee0a24, #ff6034)" block round>下一步</van-button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { showToast } from 'vant';
import { doSendMsgCode, doCheckCode } from '@/api/user'
export default defineComponent({
  data () {
    return {
      tel: '',
      telcode: '',
      text: '获取验证码',
      time: 10,
      btnFlag: false
    }
  },
  mounted () {
    this.tel = localStorage.getItem('tel')!
  },
  computed: {
    flag () {
      return this.telcode === ''
    }
  },
  methods: {
    sendCode () {
      doSendMsgCode({ tel: this.tel }).then(res => {
        console.log(res.data)
      })

      const timer = setInterval(() => {
        this.time--
        if (this.time === 0) {
          this.text = `获取验证码`
          this.btnFlag = false
          this.time = 10
          clearInterval(timer)
        } else {
          this.text = `重新发送(${this.time}s)`
          this.btnFlag = true
        }
      }, 1000)
    },
    checkCode () {
      doCheckCode({ tel: this.tel, telcode: this.telcode }).then(res => {
        if (res.data.code === '10007') {
          showToast({
            message: '验证码错误',
            position: 'bottom',
          });
        } else {
          this.$router.push('/register/pwd')
        }
      })
    }
  }
})
</script>

<style lang="stylus">
.register-form
  width calc(100% - 50px)
  // min-height 300px
  // background-color #00f
  margin 10px 25px 10px 25px
  padding 10px 0
  .form-input
    height 50px
    // background-color #0f0
    display flex
    align-items center
    border-bottom 1px solid #efefef
    overflow hidden
    .sms-btn
      border-color rgba(226,35,30,.2)
      padding 0 20px
  .form-btn
    margin-top 59px
    .van-button
      height 50px
      font-size 16px
      font-weight bold
      box-shadow: 0 1px 10px 0 rgb(255 62 62 / 20%);
      background-color: #efefef;
</style>
5.9.9 实现注册第三步功能
<!-- src/views/register/components/SetPassword.vue -->
<template>
  <div class="register-form">
    <div class="form-input">
      <van-field v-model="password" :type="type ? 'password' : 'text'" placeholder="请设置6-20位登录密码" clearable @click-right-icon="type = !type" :right-icon="type ? 'closed-eye' : 'eye-o'"/>
    </div>
    <div class="form-tip" :style="{color: color}">
      密码由6-20位大小写字母数字等组成
    </div>
    <div class="form-btn">
      <van-button @click="finish" :disabled="flag" color="linear-gradient(to right, #ee0a24, #ff6034)" block round>完成</van-button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { showToast } from 'vant';
import { doFinishRegister } from '@/api/user'
export default defineComponent({
  data () {
    return {
      tel: '',
      password: '',
      type: true,
      color: '#c6c6c6'
    }
  },
  mounted () {
    this.tel = localStorage.getItem('tel')!
  },
  computed: {
    flag () {
      // return /^\S*(?=\S{6,20})(?=\S*\d)(?=\S*[A-Z])(?=\S*[a-z])\S*$/.test(this.password)
      return this.password === ''
    }
  },
  methods: {
    finish () {
      if (/^\S*(?=\S{6,20})(?=\S*\d)(?=\S*[A-Z])(?=\S*[a-z])\S*$/.test(this.password)) {
        // 调用接口
        this.color = '#c6c6c6'
        doFinishRegister({ tel: this.tel, password: this.password }).then(() => {
          // 删除本地手机号
          localStorage.removeItem('tel')
          // 去登录 --- 登录   1  2  3
          this.$router.go(-3)
        })
      } else {
        this.color = '#f66'
        showToast({
          message: '密码格式错误',
          position: 'bottom',
        });
      }
    }
  }
})
</script>

<style lang="stylus">
.register-form
  width calc(100% - 50px)
  // min-height 300px
  // background-color #00f
  margin 10px 25px 10px 25px
  padding 10px 0
  .form-input
    height 50px
    // background-color #0f0
    display flex
    align-items center
    border-bottom 1px solid #efefef
  .form-tip
    color: #c6c6c6
    font-size 14px
    margin-top 9px
  .form-btn
    margin-top 59px
    .van-button
      height 50px
      font-size 16px
      font-weight bold
      box-shadow: 0 1px 10px 0 rgb(255 62 62 / 20%);
      background-color: #efefef;
</style>

5.10 登录实现

5.10.1 登录思路

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qWhS9FoA-1673602369093)(assets/image-20220923074218453.png)]

5.10.2 构建登录页面
<!-- src/views/login/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="登录"
        left-arrow
        @click-left="$router.back()"
        :border="false"
      />
    </header>
    <div class="content register-content">
      <div class="register-form">
        <div class="form-input">
          <van-field v-model="loginname" placeholder="账户名/邮箱/手机号" clearable/>
        </div>
        <div class="form-input">
          <van-field v-model="password" :type="type ? 'password' : 'text'" placeholder="请设置6-20位登录密码" clearable @click-right-icon="type = !type" :right-icon="type ? 'closed-eye' : 'eye-o'"/>
        </div>
        <div class="form-btn">
          <van-button :disabled="!flag" @click="login" color="linear-gradient(to right, #ee0a24, #ff6034)" block round>登录</van-button>
        </div>
      </div>
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';

export default defineComponent({
  data () {
    return {
      loginname: '',
      password: '',
      type: true,
    }
  },
  computed: {
    flag () {
      return this.loginname !== '' && this.password !==''
    }
  },
  methods: {
    login () {
      
    }
  }
})
</script>

<style lang='stylus'>
.register-content 
  background-color #fff
.register-form
  width calc(100% - 50px)
  // min-height 300px
  // background-color #00f
  margin 10px 25px 10px 25px
  padding 10px 0
  .form-input
    height 50px
    // background-color #0f0
    display flex
    align-items center
    border-bottom 1px solid #efefef
  .form-tip
    color: #c6c6c6
    font-size 14px
    margin-top 9px
  .form-btn
    margin-top 59px
    .van-button
      height 50px
      font-size 16px
      font-weight bold
      box-shadow: 0 1px 10px 0 rgb(255 62 62 / 20%);
      background-color: #efefef;
</style>
5.10.3 注册登录路由
// src/router/index.ts
import  { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, Router } from 'vue-router'
// 直接引入组件,不管你用不用这个页面,我都引入了 --- 静态导入
import Home from '@/views/home/index.vue'
import Footer from '@/components/Footer.vue'
import { CheckTel, SendTelCode, SetPassword } from '@/views/register/components'
// import Kind from '@/views/kind/index.vue'
// import Cart from '@/views/cart/index.vue'
// import User from '@/views/user/index.vue'
// 使用路由懒加载 - 访问该路由时才加载该组件    ---  动态导入
const Kind = () => import('@/views/kind/index.vue')
const Cart = () => import('@/views/cart/index.vue')
const User = () => import('@/views/user/index.vue')
const Detail = () => import('@/views/detail/index.vue')
const Vdo = () => import('@/views/video/index.vue')

const Register = () => import('@/views/register/index.vue')
const Login = () => import('@/views/login/index.vue')



const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home',
    name: 'home',
    // component: Home
    components: {
      default: Home,
      footer: Footer
    }
  },
  {
    path: '/kind',
    name: 'kind',
    // component: Kind
    components: {
      default: Kind,
      footer: Footer
    }
  },
  {
    path: '/cart',
    name: 'cart',
    // component: Cart
    components: {
      default: Cart,
      footer: Footer
    }
  },
  {
    path: '/user',
    name: 'user',
    // component: User
    components: {
      default: User,
      footer: Footer
    }
  },
  {
    path: '/detail/:proid',
    name: 'detail',
    // component: Detail
    components: { // 与 component: Detail 等价
      default: Detail
    }
  },
  {
    path: '/vdo',
    name: 'vdo',
    components: {
      default: Vdo
    }
  },
  {
    path: '/register',
    name: 'register',
    redirect: '/register/index',
    component: Register,
    children: [
      {
        path: 'index', //  /register/index
        component: CheckTel
      },
      {
        path: 'sms', //  /register/sms
        component: SendTelCode
      },
      {
        path: 'pwd', //  /register/pwd
        component: SetPassword
      }
    ]
  },
  {
    path: '/login',
    name: 'login',
    component: Login
  },
]

const router: Router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
5.10.4 实现登录功能
<!-- src/views/login/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="登录"
        left-arrow
        @click-left="$router.back()"
        :border="false"
      />
    </header>
    <div class="content register-content">
      <div class="register-form">
        <div class="form-input">
          <van-field v-model="loginname" placeholder="账户名/邮箱/手机号" clearable/>
        </div>
        <div class="form-input">
          <van-field v-model="password" :type="type ? 'password' : 'text'" placeholder="请设置6-20位登录密码" clearable @click-right-icon="type = !type" :right-icon="type ? 'closed-eye' : 'eye-o'"/>
        </div>
        <div class="form-btn">
          <van-button :disabled="!flag" @click="login" color="linear-gradient(to right, #ee0a24, #ff6034)" block round>登录</van-button>
        </div>
        <div style="float: right; margin-top: 20px">
          <router-link to="/register">手机号立即注册</router-link>
        </div>
      </div>
    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { doLogin } from '@/api/user'
import { showConfirmDialog, showToast } from 'vant'
export default defineComponent({
  data () {
    return {
      loginname: '18818007814',
      password: 'Ty2206',
      type: true,
    }
  },
  computed: {
    flag () {
      return this.loginname !== '' && this.password !==''
    }
  },
  methods: {
    login () {
      doLogin({ loginname: this.loginname, password: this.password }).then(res => {
        if (res.data.code === '10010') {
          // 用户不存在
          showConfirmDialog({
            message:
              '该账户未注册,是否立即注册。',
          })
            .then(() => {
              // on confirm
              // ** 登录  注册
              this.$router.push('/register/index')
            })
            .catch(() => {
              // on cancel
            })
        } else if (res.data.code === '10011') {
          // 密码错误
          showToast({
            message: '密码错误',
            position: 'bottom',
          });
        } else {
          // 登录成功
          console.log(res.data.data)
          localStorage.setItem('token', res.data.data.token)
          localStorage.setItem('userid', res.data.data.userid)
          this.$router.back()
        }
      })
    }
  }
})
</script>

<style lang='stylus'>
.register-content 
  background-color #fff
.register-form
  width calc(100% - 50px)
  // min-height 300px
  // background-color #00f
  margin 10px 25px 10px 25px
  padding 10px 0
  .form-input
    height 50px
    // background-color #0f0
    display flex
    align-items center
    border-bottom 1px solid #efefef
  .form-tip
    color: #c6c6c6
    font-size 14px
    margin-top 9px
  .form-btn
    margin-top 59px
    .van-button
      height 50px
      font-size 16px
      font-weight bold
      box-shadow: 0 1px 10px 0 rgb(255 62 62 / 20%);
      background-color: #efefef;
</style>

5.11 加入购物车

前端校验用户登录状态,如果登录,调用后端接口加入购物车,如果前端校验未登录,需要跳转到登录页面

后端校验未登录也需要跳转到登录页面

5.11.1 重新封装 数据请求
// src/utils/request.ts
import axios from 'axios'
import router from '@/router'
const isDev = process.env.NODE_ENV === 'development' // 真 - 开发环境,假-生产环境

// http://121.89.205.189:3000/apidoc/
// npm run dev  走?后的
// npm run build 走:后的
const ins = axios.create({
  baseURL: isDev ? 'http://121.89.205.189:3000/api' : 'http://121.89.205.189:3000/api'
})

// 拦截器
ins.interceptors.request.use((config: any) => {
  (config.headers!.token as string) = localStorage.getItem('token')! // +++++
  return config
}, (err) => {
  return Promise.reject(err)
})

ins.interceptors.response.use((response: any) => {
  // 判断登录标识 是否有效,如果无效跳转至登录页面,如果有效,不做操作
  if (response.data.code === '10119') {
    // 没有传递token 或者 token过期
    // 跳转到登录页面 重新登录
    router.push('/login')
    return null
  } else {
    return response
  }
}, (err) => {
  return Promise.reject(err)
})

export default ins
5.11.2 封装购物车相关数据请求
// src/api/cart.ts
import request from '@/utils/request'

// 加入购物车
export function addCart (params: { userid: string, proid: string, num: number }) {
  return request.post('/cart/add', params)
}

// 获取购物车列表数据
export function getCartListData (params: { userid: string }) {
  return request.post('/cart/list', params)
}

// 删除某个用户的购物车的所有数据
export function removeAllData (params: { userid: string }) {
  return request.post('/cart/removeall', params)
}

// 删除某个用户的一条购物车的数据
export function removeOneData (params: { cartid: string }) {
  return request.post('/cart/remove', params)
}

// 更新某个用户的一条购物车的数据的选中状态
export function selectOneData (params: { cartid: string, flag: boolean }) {
  return request.post('/cart/selectone', params)
}

// 更新某个用户的购物车的所有数据的选中状态
export function selectAllData (params: { userid: string, type: boolean }) {
  return request.post('/cart/selectall', params)
}

// 更新某个用户的购物车的某个产品的数量
export function updateOneDataNum (params: { cartid: string, num: number }) {
  return request.post('/cart/updatenum', params)
}

// 推荐商品接口
export function getCartRecommendData () {
  return request.get('/pro/recommendlist')
}
5.11.3 加入购物车
<!-- src/views/detail/index.vue -->
<template>
  <div class="box">
    <header class="header detail-header" >
      <div class="header1" :class="op">
        <!-- <van-nav-bar left-arrow >
          <template #right>
            <van-icon name="search" size="18" />
          </template>
        </van-nav-bar> -->
        <ul>
          <li>
            <van-icon name="arrow-left" size="18" />
          </li>
          <li></li>
          <li>
            <van-icon name="weapp-nav" size="18" />
          </li>
        </ul>
      </div>
      <div class="header2" :class="op">
        <van-nav-bar left-arrow>
          <template #right>
            <van-icon name="weapp-nav" size="18" />
          </template>
          <template #title>
            <ul class="header-color">
              <li :class="scrollTop < recommendTop - 44 ? 'active' : ''" @click="backTop(0)">商品</li>
              <li :class="scrollTop >= recommendTop - 44 ? 'active' : ''" @click="backTop(recommendTop - 44)">推荐</li>
            </ul>
          </template>
        </van-nav-bar>
      </div>
    </header>
    <div class="content" @scroll="onScroll" ref="content">
      <Banner :list="banners" />
      <ProInfo 
        :brand="brand"
        :category="category"
        :desc="desc"
        :discount="discount"
        :originprice="originprice"
        :proname="proname"
        :sales="sales"
        :stock="stock"
      />
      <div class="recommendBox" ref="recommend">
        <h4 >猜你喜欢</h4>
        <Pro :proList="recommendList" />
      </div>

      <van-action-bar>
        <van-action-bar-icon icon="chat-o" text="客服" color="#ee0a24" />
        <van-action-bar-icon icon="cart-o" @click="toCart" text="购物车" />
        <van-action-bar-icon icon="star-o" text="收藏" color="#ff5000" />
        <van-action-bar-button @click="addCartFn" type="warning" v-if="issale === 1" text="加入购物车" />
        <van-action-bar-button type="danger" disabled v-else text="商品已下架" />
      </van-action-bar>
      <div style="height: 60px"></div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { showToast } from 'vant'
import { getProDetail, getRecommendList } from '@/api/detail'
import { addCart } from '@/api/cart'
import { Banner, ProInfo, Pro } from './components'
import type { IPro } from './detail';
interface IData {
  proid: string
  banners: string[]
  brand: string
  category: string
  desc: string
  discount: number
  img1: string
  isrecommend: number
  issale: number
  isseckill: number
  originprice: number
  proname: string
  sales: number
  stock: number
  recommendList: IPro[]
  op: string,
  recommendTop: number
  scrollTop: number
}
export default defineComponent({
  components: {
    Banner, ProInfo, Pro
  },
  data (): IData {
    return {
      proid: '',
      banners: [],
      brand: '',
      category: '',
      desc: '',
      discount: 0,
      img1: '',
      isrecommend: 0,
      issale: 0,
      isseckill: 0,
      originprice: 0,
      proname: '',
      sales: 0,
      stock: 0,
      recommendList: [],
      op: 'op0',
      recommendTop: 0,
      scrollTop: 0
    }
  },
  mounted () {
    console.log(this)
    // this.proid = String(this.$route.params.proid)
    this.proid = this.$route.params.proid as string
    getProDetail(this.proid).then(res => {
      // console.log(result)
      const result = res.data.data
      this.banners = result.banners[0].split(',')
      this.brand = result.brand
      this.category = result.category
      this.desc = result.desc
      this.discount = result.discount
      this.img1 = result.img1
      this.isrecommend = result.isrecommend
      this.issale = result.issale
      this.isseckill = result.isseckill
      this.originprice = result.originprice
      this.proname = result.proname
      this.sales = result.sales
      this.stock = result.stock
      
      // 动态计算某个元素距页面顶部的距离
      this.$nextTick(() => {
        console.log((this.$refs.recommend as HTMLDivElement).offsetTop)
        this.recommendTop = (this.$refs.recommend as HTMLDivElement).offsetTop
      })
    })

    getRecommendList().then(res => {
      this.recommendList = res.data.data
    })
  },
  watch: { // 点击猜你喜欢的商品后 路由变化 重新渲染数据
    $route (newVal) {
      this.proid = newVal.params.proid
      getProDetail(this.proid).then(res => {
        // console.log(result)
        const result = res.data.data
        this.banners = result.banners[0].split(',')
        this.brand = result.brand
        this.category = result.category
        this.desc = result.desc
        this.discount = result.discount
        this.img1 = result.img1
        this.isrecommend = result.isrecommend
        this.issale = result.issale
        this.isseckill = result.isseckill
        this.originprice = result.originprice
        this.proname = result.proname
        this.sales = result.sales
        this.stock = result.stock

        // 动态计算某个元素距页面顶部的距离
        this.$nextTick(() => {
          // console.log((this.$refs.recommend as HTMLDivElement).offsetTop);
          (this.$refs.content as HTMLDivElement).scrollTop = 0
          this.recommendTop = (this.$refs.recommend as HTMLDivElement).offsetTop
        })
      })
    }
  },
  methods: {
    onScroll () {
      // 核心算法 计算 滚动比例
      console.log((this.$refs.content as HTMLDivElement).scrollTop)
      this.scrollTop = (this.$refs.content as HTMLDivElement).scrollTop
      const m = Math.floor((this.$refs.content as HTMLDivElement).scrollTop / 10)
      if (m > 10) {
        this.op = 'op10'
      } else {
        this.op = 'op' + m
      }
    },
    backTop (num: number) {
      (this.$refs.content as HTMLDivElement).scrollTop = num
    },
    addCartFn () {
      // 前端判断用户有无登录
      if (localStorage.getItem('userid')) {
        addCart({
          userid: localStorage.getItem('userid')!,
          proid: this.proid,
          num: 1
        }).then(res => {
          console.log(res)
          if (res) {
            showToast({
              message: '加入购物车成功'
            })
          }
          
        })
      } else {
        this.$router.push('/login')
      }
    },
    toCart () {
       if (localStorage.getItem('userid')) {
        this.$router.push('/cart')
       } else {
        this.$router.push('/login')
       }
    }
  }
})
</script>

<style lang="stylus">
// 层级修改背景
.container
  .box
    .header
      background-color #f66
      &.detail-header
        background-color transparent
.detail-header
  position fixed
  top 0
  width 100%
  z-index 999
  // opacity 0
  // background-color #fff
  .header1 
    position fixed
    width 100%
    top 0
    height 0.44rem
    opacity 1
    &.op0 
      opacity 1
    &.op1
      opacity 0.9
    &.op2
      opacity 0.8
    &.op3
      opacity 0.7
    &.op4
      opacity 0.6
    &.op5
      opacity 0.5
    &.op6
      opacity 0.4
    &.op7
      opacity 0.3
    &.op8
      opacity 0.2
    &.op9
      opacity 0.1
    &.op10
      opacity 0
    ul
      display flex
      height 100%
      // justify-content space-between
      li
        // flex 1
        height 100%
        display flex
        justify-content center
        align-items center
        color #000
        &:nth-child(1),   &:nth-child(3){
          width 50px
        }
        &:nth-child(2) {
          flex 1
        }
        
  .header2
    position fixed
    width 100%
    top 0
    color #000
    &.op0 
      opacity 0
    &.op1
      opacity 0.1
    &.op2
      opacity 0.2
    &.op3
      opacity 0.3
    &.op4
      opacity 0.4
    &.op5
      opacity 0.5
    &.op6
      opacity 0.6
    &.op7
      opacity 0.7
    &.op8
      opacity 0.8
    &.op9
      opacity 0.9
    &.op10
      opacity 1
    .header-color
      color #000  
      display flex  
      li
        flex 1
        &:nth-child(1) {
          margin-right 10px
        }
        &:nth-child(2) {
          margin-left 10px
        }
        &.active 
          border-bottom 3px solid #f66
.recommendBox
  width 100%
  min-height 300px
  border-radius 16px
  background-color #fff
  margin-top 15px
  h4
    padding 15px
</style>

5.12 购物车功能实现

5.12.1 基本购物车结构
<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-card
          num="2"
          price="2.00"
          desc="描述信息"
          title="商品标题"
          thumb="https://fastly.jsdelivr.net/npm/@vant/assets/ipad.jpeg"
        />
        <van-submit-bar :price="3050" button-text="提交订单" >
          <van-checkbox >全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';

export default defineComponent({
  data () {
    return {
      cartList: []
    }
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    }
  }
})
</script>

<style lang='stylus'>
  
</style>
5.12.2 请求购物车的数据并且渲染
// src/views/cart/cart.d.ts
export interface ICartItem {
  cartid: string
  discount: number
  flag: boolean
  img1: string
  num: number
  originprice: number
  proid: string
  proname: string
  userid: string
}
<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-card
          v-for="item of cartList"
          :num="item.num"
          :price="item.originprice"
          :title="item.proname"
          :thumb="item.img1"
        />
        <van-submit-bar :price="3050" button-text="提交订单" >
          <van-checkbox >全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { getCartListData } from '@/api/cart'
import type { ICart } from './cart';

interface IData {
  cartList: ICart[]
}
export default defineComponent({
  data (): IData {
    return {
      cartList: []
    }
  },
  mounted () {
    getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
      console.log(res.data)
      if (res.data.code === '10020') {
        this.cartList = []
      } else {
        this.cartList = res.data.data
      }
    })
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    }
  }
})
</script>

<style lang='stylus'>
  
</style>
5.12.3 删除数据

异步删除 - 全局变量传值

<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-swipe-cell v-for="item of cartList" :key="item.cartid">
          <van-card
            :num="item.num"
            :price="item.originprice"
            :title="item.proname"
            :thumb="item.img1"
          />
          <template #right>
            <van-button square text="删除" @click="deleteItem(item.cartid)" type="danger" class="delete-button" />
          </template>
        </van-swipe-cell>
      
        <van-submit-bar :price="3050" button-text="提交订单" >
          <van-checkbox >全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showConfirmDialog } from 'vant'
import { getCartListData, removeOneData } from '@/api/cart'
import type { ICart } from './cart';

interface IData {
  cartList: ICart[]
}
export default defineComponent({
  data (): IData {
    return {
      cartList: []
    }
  },
  mounted () {
    // getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
    //   console.log(res.data)
    //   if (res.data.code === '10020') {
    //     this.cartList = []
    //   } else {
    //     this.cartList = res.data.data
    //   }
    // })
    this.getCartListDataFn()
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    }
  },
  methods: {
    getCartListDataFn () {
      getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
        // console.log(res.data)
        if (res.data.code === '10020') {
          this.cartList = []
        } else {
          this.cartList = res.data.data
        }
      })
    },
    deleteItem (cartid: string) {
      showConfirmDialog({
        message:
          '便宜不等人,请三思而行',
      })
        .then(() => {
          // on confirm
          removeOneData({ cartid }).then(() => {
            this.getCartListDataFn()
          })
        })
        .catch(() => {
          // on cancel
        });
    }
  }
})
</script>

<style lang='stylus'>
.goods-card {
  margin: 0;
  background-color: #f66;
}

.delete-button {
  height: 100%;
}
</style>
5.12.4 修改购物车数量

使用插槽自定义数量位置,使用步进器组件完成数量更改,传参传对象

<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-swipe-cell v-for="item of cartList" :key="item.cartid">
          <van-card
            :price="item.originprice"
            :title="item.proname"
            :thumb="item.img1"
          >
            <template #num>
              <van-stepper @change="changeNum(item)" v-model="item.num" theme="round" button-size="22" disable-input />
            </template>
          </van-card>
          <template #right>
            <van-button square text="删除" @click="deleteItem(item.cartid)" type="danger" class="delete-button" />
          </template>
        </van-swipe-cell>
      
        <van-submit-bar :price="3050" button-text="提交订单" >
          <van-checkbox >全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showConfirmDialog } from 'vant'
import { getCartListData, removeOneData, updateOneDataNum } from '@/api/cart'
import type { ICart } from './cart';

interface IData {
  cartList: ICart[]
}
export default defineComponent({
  data (): IData {
    return {
      cartList: []
    }
  },
  mounted () {
    // getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
    //   console.log(res.data)
    //   if (res.data.code === '10020') {
    //     this.cartList = []
    //   } else {
    //     this.cartList = res.data.data
    //   }
    // })
    this.getCartListDataFn()
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    }
  },
  methods: {
    getCartListDataFn () {
      getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
        // console.log(res.data)
        if (res.data.code === '10020') {
          this.cartList = []
        } else {
          this.cartList = res.data.data
        }
      })
    },
    deleteItem (cartid: string) {
      showConfirmDialog({
        message:
          '便宜不等人,请三思而行',
      })
        .then(() => {
          // on confirm
          removeOneData({ cartid }).then(() => {
            this.getCartListDataFn()
          })
        })
        .catch(() => {
          // on cancel
        });
    },
    changeNum (item: ICart) {
      console.log(item)
      updateOneDataNum({ cartid: item.cartid, num: item.num }).then(() => {
        this.getCartListDataFn()
      })
    }
  }
})
</script>

<style lang='stylus'>
.goods-card {
  margin: 0;
  background-color: #f66;
}

.delete-button {
  height: 100%;
}
</style>
5.12.5 计算总价和总数量

总数位0 按钮不可点

<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-swipe-cell v-for="item of cartList" :key="item.cartid">
          <van-card
            :price="item.originprice"
            :title="item.proname"
            :thumb="item.img1"
          >
            <template #num>
              <van-stepper @change="changeNum(item)" v-model="item.num" theme="round" button-size="22" disable-input />
            </template>
          </van-card>
          <template #right>
            <van-button square text="删除" @click="deleteItem(item.cartid)" type="danger" class="delete-button" />
          </template>
        </van-swipe-cell>
      
        <van-submit-bar :disabled="totalNum <= 0" :price="totalPrice" :button-text="totalNum > 0 ? `去结算(${totalNum})` : '去结算'" >
          <van-checkbox >全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showConfirmDialog } from 'vant'
import { getCartListData, removeOneData, updateOneDataNum } from '@/api/cart'
import type { ICart } from './cart';

interface IData {
  cartList: ICart[]
}
export default defineComponent({
  data (): IData {
    return {
      cartList: []
    }
  },
  mounted () {
    // getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
    //   console.log(res.data)
    //   if (res.data.code === '10020') {
    //     this.cartList = []
    //   } else {
    //     this.cartList = res.data.data
    //   }
    // })
    this.getCartListDataFn()
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    },
    totalNum () {
      return this.cartList.reduce((sum, item) => {
        return sum + item.num
      }, 0)
    },
    totalPrice () {
      return this.cartList.reduce((sum, item) => {
        return sum + item.num * item.originprice
      }, 0) * 100 // 单位为分
    }
  },
  methods: {
    getCartListDataFn () {
      getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
        // console.log(res.data)
        if (res.data.code === '10020') {
          this.cartList = []
        } else {
          this.cartList = res.data.data
        }
      })
    },
    deleteItem (cartid: string) {
      showConfirmDialog({
        message:
          '便宜不等人,请三思而行',
      })
        .then(() => {
          // on confirm
          removeOneData({ cartid }).then(() => {
            this.getCartListDataFn()
          })
        })
        .catch(() => {
          // on cancel
        });
    },
    changeNum (item: ICart) {
      console.log(item)
      updateOneDataNum({ cartid: item.cartid, num: item.num }).then(() => {
        this.getCartListDataFn()
      })
    }
  }
})
</script>

<style lang='stylus'>
.goods-card {
  margin: 0;
  background-color: #f66;
}

.delete-button {
  height: 100%;
}
</style>

实际上总价以及总数需要选中才计算

5.12.6 全选以及单选

以 layout组件 以及checkbox 组件完成 基本布局

<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-swipe-cell style="border-bottom: 1px solid #efefef" v-for="item of cartList" :key="item.cartid">
          <van-row style="background: white">
            <van-col span="2">
              <van-checkbox style="margin-top: 40px;margin-left: 12px" v-model="item.flag"></van-checkbox>
            </van-col>
            <van-col span="22">
              <van-card
                style="background: white"
                :price="item.originprice"
                :title="item.proname"
                :thumb="item.img1"
              >
                <template #num>
                  <van-stepper @change="changeNum(item)" v-model="item.num" theme="round" button-size="22" disable-input />
                </template>
              </van-card>
            </van-col>
          </van-row>
          
          <template #right>
            <van-button square text="删除" @click="deleteItem(item.cartid)" type="danger" class="delete-button" />
          </template>
        </van-swipe-cell>
      
        <van-submit-bar :disabled="totalNum <= 0" :price="totalPrice" :button-text="totalNum > 0 ? `去结算(${totalNum})` : '去结算'" >
          <van-checkbox v-model="checked">全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showConfirmDialog } from 'vant'
import { getCartListData, removeOneData, updateOneDataNum } from '@/api/cart'
import type { ICart } from './cart';

interface IData {
  cartList: ICart[],
  checked: boolean
}
export default defineComponent({
  data (): IData {
    return {
      cartList: [],
      checked: false
    }
  },
  mounted () {
    // getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
    //   console.log(res.data)
    //   if (res.data.code === '10020') {
    //     this.cartList = []
    //   } else {
    //     this.cartList = res.data.data
    //   }
    // })
    this.getCartListDataFn()
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    },
    totalNum () {
      return this.cartList.reduce((sum, item) => {
        return sum + item.num
      }, 0)
    },
    totalPrice () {
      return this.cartList.reduce((sum, item) => {
        return sum + item.num * item.originprice
      }, 0) * 100 // 单位为分
    }
  },
  methods: {
    getCartListDataFn () {
      getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
        // console.log(res.data)
        if (res.data.code === '10020') {
          this.cartList = []
        } else {
          this.cartList = res.data.data
        }
      })
    },
    deleteItem (cartid: string) {
      showConfirmDialog({
        message:
          '便宜不等人,请三思而行',
      })
        .then(() => {
          // on confirm
          removeOneData({ cartid }).then(() => {
            this.getCartListDataFn()
          })
        })
        .catch(() => {
          // on cancel
        });
    },
    changeNum (item: ICart) {
      console.log(item)
      updateOneDataNum({ cartid: item.cartid, num: item.num }).then(() => {
        this.getCartListDataFn()
      })
    }
  }
})
</script>

<style lang='stylus'>
.goods-card {
  margin: 0;
  background-color: #f66;
}

.delete-button {
  height: 100%;
}
</style>

默认全选不选中,通过列表的数据控制全选的效果

<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-swipe-cell style="border-bottom: 1px solid #efefef" v-for="item of cartList" :key="item.cartid">
          <van-row style="background: white">
            <van-col span="2">
              <van-checkbox style="margin-top: 40px;margin-left: 12px" v-model="item.flag"></van-checkbox>
            </van-col>
            <van-col span="22">
              <van-card
                style="background: white"
                :price="item.originprice"
                :title="item.proname"
                :thumb="item.img1"
              >
                <template #num>
                  <van-stepper @change="changeNum(item)" v-model="item.num" theme="round" button-size="22" disable-input />
                </template>
              </van-card>
            </van-col>
          </van-row>
          
          <template #right>
            <van-button square text="删除" @click="deleteItem(item.cartid)" type="danger" class="delete-button" />
          </template>
        </van-swipe-cell>
      
        <van-submit-bar :disabled="totalNum <= 0" :price="totalPrice" :button-text="totalNum > 0 ? `去结算(${totalNum})` : '去结算'" >
          <van-checkbox v-model="checked">全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showConfirmDialog } from 'vant'
import { getCartListData, removeOneData, updateOneDataNum } from '@/api/cart'
import type { ICart } from './cart';

interface IData {
  cartList: ICart[],
  checked: boolean
}
export default defineComponent({
  data (): IData {
    return {
      cartList: [],
      checked: false
    }
  },
  mounted () {
    // getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
    //   console.log(res.data)
    //   if (res.data.code === '10020') {
    //     this.cartList = []
    //   } else {
    //     this.cartList = res.data.data
    //   }
    // })
    this.getCartListDataFn()
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    },
    totalNum () {
      return this.cartList.reduce((sum, item) => {
        return sum + item.num
      }, 0)
    },
    totalPrice () {
      return this.cartList.reduce((sum, item) => {
        return sum + item.num * item.originprice
      }, 0) * 100 // 单位为分
    }
  },
  methods: {
    getCartListDataFn () {
      getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
        // console.log(res.data)
        if (res.data.code === '10020') {
          this.cartList = []
        } else {
          this.cartList = res.data.data

          this.checked = this.cartList.every(item => item.flag)
        }
      })
    },
    deleteItem (cartid: string) {
      showConfirmDialog({
        message:
          '便宜不等人,请三思而行',
      })
        .then(() => {
          // on confirm
          removeOneData({ cartid }).then(() => {
            this.getCartListDataFn()
          })
        })
        .catch(() => {
          // on cancel
        });
    },
    changeNum (item: ICart) {
      console.log(item)
      updateOneDataNum({ cartid: item.cartid, num: item.num }).then(() => {
        this.getCartListDataFn()
      })
    }
  }
})
</script>

<style lang='stylus'>
.goods-card {
  margin: 0;
  background-color: #f66;
}

.delete-button {
  height: 100%;
}
</style>

点击全选控制列表选中状态

<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-swipe-cell style="border-bottom: 1px solid #efefef" v-for="item of cartList" :key="item.cartid">
          <van-row style="background: white">
            <van-col span="2">
              <van-checkbox style="margin-top: 40px;margin-left: 12px" v-model="item.flag"></van-checkbox>
            </van-col>
            <van-col span="22">
              <van-card
                style="background: white"
                :price="item.originprice"
                :title="item.proname"
                :thumb="item.img1"
              >
                <template #num>
                  <van-stepper @change="changeNum(item)" v-model="item.num" theme="round" button-size="22" disable-input />
                </template>
              </van-card>
            </van-col>
          </van-row>
          
          <template #right>
            <van-button square text="删除" @click="deleteItem(item.cartid)" type="danger" class="delete-button" />
          </template>
        </van-swipe-cell>
      
        <van-submit-bar :disabled="totalNum <= 0" :price="totalPrice" :button-text="totalNum > 0 ? `去结算(${totalNum})` : '去结算'" >
          <van-checkbox @click="checkAll" v-model="checked">全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showConfirmDialog } from 'vant'
import { getCartListData, removeOneData, updateOneDataNum, selectAllData } from '@/api/cart'
import type { ICart } from './cart';

interface IData {
  cartList: ICart[],
  checked: boolean
}
export default defineComponent({
  data (): IData {
    return {
      cartList: [],
      checked: false
    }
  },
  mounted () {
    // getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
    //   console.log(res.data)
    //   if (res.data.code === '10020') {
    //     this.cartList = []
    //   } else {
    //     this.cartList = res.data.data
    //   }
    // })
    this.getCartListDataFn()
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    },
    totalNum () {
      return this.cartList.reduce((sum, item) => {
        return sum + item.num
      }, 0)
    },
    totalPrice () {
      return this.cartList.reduce((sum, item) => {
        return sum + item.num * item.originprice
      }, 0) * 100 // 单位为分
    }
  },
  methods: {
    getCartListDataFn () {
      getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
        // console.log(res.data)
        if (res.data.code === '10020') {
          this.cartList = []
        } else {
          this.cartList = res.data.data

          this.checked = this.cartList.every(item => item.flag)
        }
      })
    },
    deleteItem (cartid: string) {
      showConfirmDialog({
        message:
          '便宜不等人,请三思而行',
      })
        .then(() => {
          // on confirm
          removeOneData({ cartid }).then(() => {
            this.getCartListDataFn()
          })
        })
        .catch(() => {
          // on cancel
        });
    },
    changeNum (item: ICart) {
      console.log(item)
      updateOneDataNum({ cartid: item.cartid, num: item.num }).then(() => {
        this.getCartListDataFn()
      })
    },
    checkAll () {
      console.log(this.checked)
      selectAllData({
        userid: localStorage.getItem('userid')!,
        type: this.checked
      }).then(() => {
        this.getCartListDataFn()
      })
    }
  }
})
</script>

<style lang='stylus'>
.goods-card {
  margin: 0;
  background-color: #f66;
}

.delete-button {
  height: 100%;
}
</style>

单个列表选中

<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
      </div>
      <div class="shop-list" v-else>
        <van-swipe-cell style="border-bottom: 1px solid #efefef" v-for="item of cartList" :key="item.cartid">
          <van-row style="background: white">
            <van-col span="2">
              <van-checkbox style="margin-top: 40px;margin-left: 12px" v-model="item.flag" @change="selectOne(item)"></van-checkbox>
            </van-col>
            <van-col span="22">
              <van-card
                style="background: white"
                :price="item.originprice"
                :title="item.proname"
                :thumb="item.img1"
              >
                <template #num>
                  <van-stepper @change="changeNum(item)" v-model="item.num" theme="round" button-size="22" disable-input />
                </template>
              </van-card>
            </van-col>
          </van-row>
          
          <template #right>
            <van-button square text="删除" @click="deleteItem(item.cartid)" type="danger" class="delete-button" />
          </template>
        </van-swipe-cell>
      
        <van-submit-bar :disabled="totalNum <= 0" :price="totalPrice" :button-text="totalNum > 0 ? `去结算(${totalNum})` : '去结算'" >
          <van-checkbox @click="checkAll" v-model="checked">全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showConfirmDialog } from 'vant'
import { getCartListData, removeOneData, updateOneDataNum, selectAllData, selectOneData } from '@/api/cart'
import type { ICart } from './cart';

interface IData {
  cartList: ICart[],
  checked: boolean
}
export default defineComponent({
  data (): IData {
    return {
      cartList: [],
      checked: false
    }
  },
  mounted () {
    // getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
    //   console.log(res.data)
    //   if (res.data.code === '10020') {
    //     this.cartList = []
    //   } else {
    //     this.cartList = res.data.data
    //   }
    // })
    this.getCartListDataFn()
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    },
    totalNum () {
      return this.cartList.reduce((sum, item) => {
        return item.flag ? sum + item.num : sum + 0
      }, 0)
    },
    totalPrice () {
      return this.cartList.reduce((sum, item) => {
        return item.flag ? sum + item.num * item.originprice: sum + 0
      }, 0) * 100 // 单位为分
    }
  },
  methods: {
    getCartListDataFn () {
      getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
        // console.log(res.data)
        if (res.data.code === '10020') {
          this.cartList = []
        } else {
          this.cartList = res.data.data

          this.checked = this.cartList.every(item => item.flag)
        }
      })
    },
    deleteItem (cartid: string) {
      showConfirmDialog({
        message:
          '便宜不等人,请三思而行',
      })
        .then(() => {
          // on confirm
          removeOneData({ cartid }).then(() => {
            this.getCartListDataFn()
          })
        })
        .catch(() => {
          // on cancel
        });
    },
    changeNum (item: ICart) {
      console.log(item)
      updateOneDataNum({ cartid: item.cartid, num: item.num }).then(() => {
        this.getCartListDataFn()
      })
    },
    checkAll () {
      console.log(this.checked)
      selectAllData({
        userid: localStorage.getItem('userid')!,
        type: this.checked
      }).then(() => {
        this.getCartListDataFn()
      })
    },
    selectOne (item: ICart) {
      console.log(item)
      selectOneData({
        cartid: item.cartid,
        flag: item.flag
      }).then(() => {
        this.getCartListDataFn()
      })
    }
  }
})
</script>

<style lang='stylus'>
.goods-card {
  margin: 0;
  background-color: #f66;
}

.delete-button {
  height: 100%;
}
</style>
5.12.7 推荐列表实现
<!-- src/views/cart/components/Pro.vue -->
<template>
  <ul class="proList">
    <li class="proItem" v-for="item of proList" :key="item.proid" @click="toDetail(item.proid)">
      <div class="itemImage">
        <van-image :src="item.img1"></van-image>
      </div>
      <div class="itemInfo">
        <div class="title van-multi-ellipsis--l2">{{ item.proname }}</div>
        <div class="price">¥{{ item.originprice }}</div>
        <div class="other">
          <van-tag type="danger">{{ item.category }}</van-tag>
        </div>
      </div>
    </li>
    <!-- <li class="proItem">
      <div class="itemImage">
        <van-image src=""></van-image>
      </div>
      <div class="itemInfo">
        <div class="title">产品名称</div>
        <div class="price">¥1999</div>
        <div class="other">苹果</div>
      </div>
    </li>
    <li class="proItem">
      <div class="itemImage">
        <van-image src=""></van-image>
      </div>
      <div class="itemInfo">
        <div class="title">产品名称</div>
        <div class="price">¥1999</div>
        <div class="other">苹果</div>
      </div>
    </li> -->
  </ul>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import type { PropType } from 'vue'
import type { IPro } from '../home'

export default defineComponent({
  props: {
    proList: Array as PropType<IPro[]>
  },
  methods: {
    toDetail (proid: string) {
      this.$router.push(`/detail/${proid}`)
      // this.$router.push('/detail/' + proid)
      // this.$router.push({ path: '/detail/' + proid })
      // this.$router.push({ name: 'detail', params: { proid: proid } })
    }
  }
})
</script>

<style lang='stylus'>
.proList
  display flex
  flex-wrap wrap
  .proItem
    width 46%
    margin 8px 2%
    min-height 2.6rem
    background-color #fff
    border-radius 10px
    overflow hidden
    .itemImage
      width 100%
      height 1.9rem
      .van-image
        width 100%
        height 100%
        display block
    .itemInfo
      padding 10px
      .price 
        color #f66
        margin-top 5px
      .other
        margin-top 5px
</style>
// src/views/cart/cart.d.ts
export interface ICart {
  cartid: string
  discount: number
  flag: boolean
  img1: string
  num: number
  originprice: number
  proid: string
  proname: string
  userid: string
}

export interface IPro {
  banners: string[]
  brand: string
  category: string
  desc: string
  discount: number
  img1: string
  img2: string
  img3: string
  img4: string
  isrecommend: number
  issale: number
  isseckill: number
  originprice: number
  proid: string
  proname: string
  sales: number
  stock: number
}
<!-- src/views/cart/index.vue -->
<template>
  <div class="box">
    <header class="header">
      <van-nav-bar
        title="购物车"
        left-arrow
        @click-left="$router.back()"
      />
    </header>
    <div class="content">
      <div class="no-shop" v-if="empty">
        <van-empty description="购物车空空如也" image="https://img1.baidu.com/it/u=1635842218,3687466488&fm=253&fmt=auto&app=120&f=JPEG?w=610&h=500" image-size="120">
          <van-button round color="linear-gradient(to right, #ee0a24, #ff6034)" class="bottom-button">立即购物</van-button>
        </van-empty>
        <van-divider :style="{ color: '#1989fa', borderColor: '#1989fa', padding: '0 16px' }">可能你喜欢</van-divider>
        <Pro :proList="proList" />
      </div>
      <div class="shop-list" v-else>
        <van-swipe-cell style="border-bottom: 1px solid #efefef" v-for="item of cartList" :key="item.cartid">
          <van-row style="background: white">
            <van-col span="2">
              <van-checkbox style="margin-top: 40px;margin-left: 12px" v-model="item.flag" @change="selectOne(item)"></van-checkbox>
            </van-col>
            <van-col span="22">
              <van-card
                style="background: white"
                :price="item.originprice"
                :title="item.proname"
                :thumb="item.img1"
              >
                <template #num>
                  <van-stepper @change="changeNum(item)" v-model="item.num" theme="round" button-size="22" disable-input />
                </template>
              </van-card>
            </van-col>
          </van-row>
          
          <template #right>
            <van-button square text="删除" @click="deleteItem(item.cartid)" type="danger" class="delete-button" />
          </template>
        </van-swipe-cell>
        <van-divider :style="{ color: '#1989fa', borderColor: '#1989fa', padding: '0 16px' }">猜你还想要</van-divider>
        <Pro :proList="proList" />
        <van-submit-bar :disabled="totalNum <= 0" :price="totalPrice" :button-text="totalNum > 0 ? `去结算(${totalNum})` : '去结算'" >
          <van-checkbox @click="checkAll" v-model="checked">全选</van-checkbox>
        </van-submit-bar>
      </div>

    </div>
  </div>
</template>
<script lang='ts'>
import { defineComponent } from 'vue';
import { showConfirmDialog } from 'vant'
import { getCartListData, removeOneData, updateOneDataNum, selectAllData, selectOneData, getCartRecommendData } from '@/api/cart'
import type { ICart, IPro } from './cart';
import Pro from './components/Pro.vue'

interface IData {
  cartList: ICart[],
  checked: boolean,
  proList: IPro[]
}
export default defineComponent({
  components: {
    Pro
  },
  data (): IData {
    return {
      cartList: [],
      checked: false,
      proList: []
    }
  },
  mounted () {
    // getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
    //   console.log(res.data)
    //   if (res.data.code === '10020') {
    //     this.cartList = []
    //   } else {
    //     this.cartList = res.data.data
    //   }
    // })
    this.getCartListDataFn()
  },
  computed: {
    empty () {
      return this.cartList.length === 0
    },
    totalNum () {
      return this.cartList.reduce((sum, item) => {
        return item.flag ? sum + item.num : sum + 0
      }, 0)
    },
    totalPrice () {
      return this.cartList.reduce((sum, item) => {
        return item.flag ? sum + item.num * item.originprice: sum + 0
      }, 0) * 100 // 单位为分
    }
  },
  methods: {
    getCartListDataFn () {
      getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
        // console.log(res.data)
        if (res.data.code === '10020') {
          this.cartList = []
        } else {
          this.cartList = res.data.data

          this.checked = this.cartList.every(item => item.flag)
        }

        // 获取推荐列表
        this.getRecommendDataFn()
      })
    },
    getRecommendDataFn () {
      // if (this.empty) {
      //   接口1
      // } else {
      //   接口2
      // }
      getCartRecommendData().then((res) => {
        this.proList = res.data.data
      })
    },
    deleteItem (cartid: string) {
      showConfirmDialog({
        message:
          '便宜不等人,请三思而行',
      })
        .then(() => {
          // on confirm
          removeOneData({ cartid }).then(() => {
            this.getCartListDataFn()
          })
        })
        .catch(() => {
          // on cancel
        });
    },
    changeNum (item: ICart) {
      console.log(item)
      updateOneDataNum({ cartid: item.cartid, num: item.num }).then(() => {
        this.getCartListDataFn()
      })
    },
    checkAll () {
      console.log(this.checked)
      selectAllData({
        userid: localStorage.getItem('userid')!,
        type: this.checked
      }).then(() => {
        this.getCartListDataFn()
      })
    },
    selectOne (item: ICart) {
      console.log(item)
      selectOneData({
        cartid: item.cartid,
        flag: item.flag
      }).then(() => {
        this.getCartListDataFn()
      })
    }
  }
})
</script>

<style lang='stylus'>
.goods-card {
  margin: 0;
  background-color: #f66;
}

.delete-button {
  height: 100%;
}
</style>
5.12.8 分析接下来思路

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3GGGsXA4-1673602369095)(assets/未命名文件.png)]

5.13 提交订单

5.13.1 封装订单相关接口
// src/api/order.ts
import request from '../service/request'

// 添加订单
export function addOrderData (params: { userid: string }) {
  return request.post('/order/addOrder', params)
}

// 获取订单信息
export function getOrderListData (params: { userid: string, time: string }) {
  return request.get('/order/confirmOrder', {params})
}

export interface IOrderAddress {
  userid: string
  time: string 
  name: string
  tel: string
  province: string
  city: string
  county: string
  addressDetail: string
}
// 获取订单信息
export function updateOrderAddressData (params: IOrderAddress) {
  return request.post('/order/updateOrderAddress', params)
}

<!-- src/views/cart/IndexView.vue -->
<template>
  <header class="header">
    <van-nav-bar
      title="购物车"
      left-arrow
      @click-left="$router.back()"
    />
  </header>
  <div class="content">
    <div class="no-shop" v-if="empty">
      <van-empty description="购物车空空如也">
        <van-button round type="danger" class="bottom-button" @click="$router.push('/kind')">立即购物</van-button>
      </van-empty>
      <van-divider
        :style="{ color: '#1989fa', borderColor: '#1989fa', padding: '0 16px' }"
      >
        快点来看看
      </van-divider>
      <ProComponent :list="proList"></ProComponent>
    </div>
    <div class="shop-list" v-else>
      <van-swipe-cell
        v-for="item of cartList"
        :key="item.cartid"
        :before-close="beforeClose"
      >
      <van-row class="myItem">
        <van-col span="2">
          <van-checkbox v-model="item.flag" @change="selectOne(item)"></van-checkbox>
        </van-col>
        <van-col span="22">
          <van-card
            :price="item.originprice"
            :title="item.proname"
            :thumb="item.img1"
          >
            <template #num>
              <van-stepper v-model="item.num" theme="round" button-size="22" disable-input @change="updateNum(item)" />
            </template>
          </van-card>
        </van-col>
      </van-row>
        
        <template #right>
          <van-button square text="删除" type="danger" @click="getDeleteId(item.cartid)" class="delete-button" />
        </template>
      </van-swipe-cell>
      <van-divider
        :style="{ color: '#1989fa', borderColor: '#1989fa', padding: '0 16px' }"
      >
        可能你想要
      </van-divider>
      <ProComponent :list="proList"></ProComponent>
      <van-submit-bar @click="submit" :disabled="totalNum === 0" :price="totalPrice" :button-text="totalNum > 0  ? `去结算(${totalNum})` : `去结算`">
        <van-checkbox v-model="checked" @click="changeType">全选</van-checkbox>
      </van-submit-bar>
    </div>
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { Dialog } from 'vant'
  import { getCartListData, removeOneData, selectAllData, selectOneData, updateOneDataNum, getCartRecommendData } from '@/api/cart'
  import { addOrderData } from '@/api/order'
  import type { ICartItem } from './cart' 
  import type { IPro } from '../home/home';
  import ProComponent from './components/ProComponent.vue'
  interface IData {
    cartList: ICartItem[],
    id: string
    checked: boolean
    proList: IPro[]
  }
  export default defineComponent({
    components: {
      ProComponent
    },
    data (): IData {
      return {
        cartList: [],
        id: '',
        checked: false,
        proList: []
      }
    },
    computed: {
      empty () {
        return this.cartList.length === 0
      },

      totalNum () {  // 选中才累加
        return this.cartList.reduce((sum, item) => {
          return item.flag ? sum += item.num : sum += 0
        }, 0)
      },

      totalPrice () {
        return this.cartList.reduce((sum, item) => {
          return  item.flag ? sum += item.num * item.originprice : sum += 0
        }, 0) * 100
      }
    },
    mounted () {
      if (localStorage.getItem('userid')) {
        this.getCartList()
      } else {
        this.$router.push('/login')
      }

      getCartRecommendData().then(res => {
        this.proList = res.data.data
      })
    },
    methods: {
      getCartList () {
        getCartListData({ userid: localStorage.getItem('userid')! }).then(res => {
          console.log(res.data)
          if (res.data.code === '10020') {
            this.cartList = []
          } else {
            this.cartList = res.data.data
            this.checked = this.cartList.every(item => item.flag)
          }
        })
      },
      beforeClose ({ position }: { position: 'left' | 'right' | 'cell' | 'outside'}): any {
        console.log(position)
        switch (position) {
          case 'right':
            return new Promise<void>((resolve) => {
              Dialog.confirm({
                title: '确定删除吗?',
              }).then(() => {
                removeOneData({cartid: this.id}).then(() => {
                  this.getCartList() // 重置列表
                  this.id = ''
                  resolve()
                })
              });
            });
          case 'outside':
            return true
        }
      },
      getDeleteId (cartid: string) {
        console.log(cartid)
        this.id =cartid
      },
      updateNum ({ cartid, num }: ICartItem) {
        console.log(cartid, num)
        updateOneDataNum({ cartid, num }).then(() => {
          this.getCartList()
        })
      },
      changeType () {
        console.log(this.checked)
        selectAllData({ userid: localStorage.getItem('userid')!, type: this.checked}).then(() => {
          this.getCartList()
        })
      },
      selectOne ({cartid, flag}: ICartItem) {
        console.log(cartid, flag)
        selectOneData({cartid, flag}).then(() => {
          this.getCartList()
        })
      },
      submit () { // +++++++++++
        addOrderData({ userid: localStorage.getItem('userid')! }).then(res => {
          console.log(res.data)
          // this.$router.push('/order/' + res.data.time) // /order/:time
          this.$router.push('/order?time=' + res.data.time) // /order
        })
      }
    }
  })
</script>

<style lang="scss" scoped>
  .delete-button {
    height: 100%;
  }

  .myItem {
    background: var(--van-card-background-color);
    .van-col--2 {
      display: flex;
      justify-content: center;
      align-items: center;
    }
  }
</style>

5.14 确定订单实现

5.14.1 创建页面以及更新路由
<!-- src/views/order/IndexView.vue -->
<template>
  <header class="header">
    <van-nav-bar
      title="确定订单"
      left-arrow
      @click-left="$router.back()"
    />
  </header>
  <div class="content">order content</div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';

  export default defineComponent({})
</script>

<style lang="scss"></style>
// src/router/index.ts
// 1.引入 创建路由的模块 以及 路由历史记录模式的模块 
// 路由的历史模式:
// createWebHistory  HTML5模式  /home /kind /cart /user  需要后端配合
// createWebHashHistory Hash 模式  /#/home   /#/kind  /#/cart  /#/user
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, Router } from 'vue-router' // 路由规则的类型 ---- 路由规则的类型注解

// 2.引入页面组件
import Footer from '../components/FooterComponent.vue'
import HomeView from './../views/home/IndexView.vue'
import KindView from './../views/kind/IndexView.vue'
import CartView from './../views/cart/IndexView.vue'
import UserView from './../views/user/IndexView.vue'
import DetailView from '../views/detail/IndexView.vue'
import RegisterView from '../views/register/IndexView.vue'
import CheckTelComponent from '../views/register/components/CheckTelComponent.vue'
import SendTelCodeComponent from '../views/register/components/SendTelCodeComponent.vue'
import SetPasswordComponent from '../views/register/components/SetPasswordComponent.vue'
import LoginView from '../views/login/IndexView.vue'
import OrderView from '../views/order/IndexView.vue' // ++++++++++

// 3.构建路由的规则
const routes: RouteRecordRaw[] = [
  { // 路由的重定向
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home', // 地址栏地址 - 路由
    name: 'home', // 命名路由  ---   唯一性
    // component: HomeView // 路由映射的页面组件
    components: {
      default: HomeView,
      footer: Footer
    }
  },
  {
    path: '/kind', 
    name: 'kind',
    // component: KindView 
    components: {
      default: KindView,
      footer: Footer
    }
  },
  {
    path: '/cart', 
    name: 'cart',
    // component: CartView 
    components: {
      default: CartView,
      footer: Footer
    }
  },
  {
    path: '/user', 
    name: 'user',
    // component: UserView 
    components: {
      default: UserView,
      footer: Footer
    }
  },
  {
    path: '/detail/:proid',
    name: 'detail',
    // component: DetailView
    components: {
      default: DetailView
    }
  },
  {
    path: '/register',
    name: 'register',
    redirect: '/register/index',
    component: RegisterView,
    children: [
      {
        path: 'index',
        component: CheckTelComponent
      },
      {
        path: 'sms',
        component: SendTelCodeComponent
      },
      {
        path: 'pwd',
        component: SetPasswordComponent
      }
    ]
  },
  {
    path: '/login',
    name: 'login',
    component: LoginView
  },
  { // ++++++++++
    path: '/order',
    name: 'order',
    component: OrderView
  }
]

// 4.生成路由
const router: Router = createRouter({
  // vue2 vue-router3 history: 'history' | 'hash' 
  history: createWebHistory(),
  routes // routes: routes 简写形式
})

// 5.暴露路由
export default router
5.14.2 布局
<!-- src/views/order/IndexView.vue -->
<template>
  <header class="header">
    <van-nav-bar
      title="确定订单"
      left-arrow
      @click-left="$router.back()"
    />
  </header>
  <div class="content">
    <van-cell center v-if="flag" title="请添加地址" is-link  />
    <van-cell center v-else title="musk 18813007814" is-link label="陕西省西安市雁塔区千锋教育" />

    <van-card
      v-for="item of orderList"
      :key="item.orderid"
      :num="item.num"
      :price="item.originprice"
      :title="item.proname"
      :thumb="item.img1"
    />
    <div class="tip">
      <p><span>原价:</span><span>{{ totalPrice }}</span></p>
      <p><span>快递费:</span><span>{{ express }}</span></p>
    </div>
    <van-submit-bar :price="totalPrice * 100 + express * 100" button-text="去支付" />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { getOrderListData } from '../../api/order'
  interface IOrderItem {
    addressDetail: string
    city: string
    county: string
    discount: number
    img1: string
    name: string
    num: number
    orderid: string
    originprice: number
    proid: string
    proname: string
    province: string
    status:number
    tel: string
    time: string
    userid: string
  }
  interface IData {
    orderList: IOrderItem[],
    time: string
    express: number
  }
  export default defineComponent({
    data (): IData {
      return {
        orderList: [],
        time: '',
        express: 0
      }
    },
    computed: {
      totalPrice () {
        return this.orderList.reduce((sum, item) => {
          return sum += item.num * item.originprice
        }, 0)
      },
      flag () { // 显示地址
        return this.orderList[0]?.tel === ''
      }
    },
    mounted () {
      // console.log(this.$route)
      this.time = (this.$route.query.time as string)
      this.express = Math.floor(Math.random() * 20)

      const userid = localStorage.getItem('userid')!
      getOrderListData({ time: this.time, userid }).then(res => {
        console.log(res.data)
        this.orderList = res.data.data
      })
    }
  })
</script>

<style lang="scss" scoped>
  .tip {
    margin-top: 15px;
    margin-right: 10px;
    text-align: right;
    p {
      display: flex;
      span {
        &:nth-child(1) {
          flex: 1;
        }
        &:nth-child(2) {
          width: 40px;
        }
      }
    }
  }
</style>
5.14.3 点击选择地址

点击进入地址选择页面

<!-- src/views/order/AddressListView.vue -->
<template>
  <header class="header"><van-nav-bar
      title="地址列表"
      left-arrow
      @click-left="$router.back()"
    /></header>
  <div class="content">
    <van-address-list
      v-model="chosenAddressId"
      :list="list"
      default-tag-text="默认"
      @add="onAdd"
    />
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

  export default defineComponent({
    data () {
      return {
        chosenAddressId: '',
        list: [],
        time: ''
      }
    },
    mounted () {
      this.time = (this.$route.query.time as string)
    },
    methods: {
      onAdd () {
        this.$router.push('/orderAddAddress?time=' + this.time)
      }
    }
  })
</script>

<style lang="scss"></style>
// src/router/index.ts
// 1.引入 创建路由的模块 以及 路由历史记录模式的模块 
// 路由的历史模式:
// createWebHistory  HTML5模式  /home /kind /cart /user  需要后端配合
// createWebHashHistory Hash 模式  /#/home   /#/kind  /#/cart  /#/user
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, Router } from 'vue-router' // 路由规则的类型 ---- 路由规则的类型注解

// 2.引入页面组件
import Footer from '../components/FooterComponent.vue'
import HomeView from './../views/home/IndexView.vue'
import KindView from './../views/kind/IndexView.vue'
import CartView from './../views/cart/IndexView.vue'
import UserView from './../views/user/IndexView.vue'
import DetailView from '../views/detail/IndexView.vue'
import RegisterView from '../views/register/IndexView.vue'
import CheckTelComponent from '../views/register/components/CheckTelComponent.vue'
import SendTelCodeComponent from '../views/register/components/SendTelCodeComponent.vue'
import SetPasswordComponent from '../views/register/components/SetPasswordComponent.vue'
import LoginView from '../views/login/IndexView.vue'
import OrderView from '../views/order/IndexView.vue'
import OrderAddressListView from '../views/order/AddressListView.vue'

// 3.构建路由的规则
const routes: RouteRecordRaw[] = [
  { // 路由的重定向
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home', // 地址栏地址 - 路由
    name: 'home', // 命名路由  ---   唯一性
    // component: HomeView // 路由映射的页面组件
    components: {
      default: HomeView,
      footer: Footer
    }
  },
  {
    path: '/kind', 
    name: 'kind',
    // component: KindView 
    components: {
      default: KindView,
      footer: Footer
    }
  },
  {
    path: '/cart', 
    name: 'cart',
    // component: CartView 
    components: {
      default: CartView,
      footer: Footer
    }
  },
  {
    path: '/user', 
    name: 'user',
    // component: UserView 
    components: {
      default: UserView,
      footer: Footer
    }
  },
  {
    path: '/detail/:proid',
    name: 'detail',
    // component: DetailView
    components: {
      default: DetailView
    }
  },
  {
    path: '/register',
    name: 'register',
    redirect: '/register/index',
    component: RegisterView,
    children: [
      {
        path: 'index',
        component: CheckTelComponent
      },
      {
        path: 'sms',
        component: SendTelCodeComponent
      },
      {
        path: 'pwd',
        component: SetPasswordComponent
      }
    ]
  },
  {
    path: '/login',
    name: 'login',
    component: LoginView
  },
  {
    path: '/order',
    name: 'order',
    component: OrderView
  },
  { // ++++++++
    path: '/orderAddress',
    name: 'orderAddress',
    component: OrderAddressListView
  }
]

// 4.生成路由
const router: Router = createRouter({
  // vue2 vue-router3 history: 'history' | 'hash' 
  history: createWebHistory(),
  routes // routes: routes 简写形式
})

// 5.暴露路由
export default router

点击进入地址列表页面

<!-- src/views/order/IndexView.vue -->
<template>
  <header class="header">
    <van-nav-bar
      title="确定订单"
      left-arrow
      @click-left="$router.back()"
    />
  </header>
  <div class="content">
    <van-cell center v-if="flag" title="请添加地址" is-link @click="toAddressList" />
    <van-cell center v-else title="musk 18813007814" is-link label="陕西省西安市雁塔区千锋教育"  @click="toAddressList"  />

    <van-card
      v-for="item of orderList"
      :key="item.orderid"
      :num="item.num"
      :price="item.originprice"
      :title="item.proname"
      :thumb="item.img1"
    />
    <div class="tip">
      <p><span>原价:</span><span>{{ totalPrice }}</span></p>
      <p><span>快递费:</span><span>{{ express }}</span></p>
    </div>
    <van-submit-bar :price="totalPrice * 100 + express * 100" button-text="去支付" />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { getOrderListData } from '../../api/order'
  interface IOrderItem {
    addressDetail: string
    city: string
    county: string
    discount: number
    img1: string
    name: string
    num: number
    orderid: string
    originprice: number
    proid: string
    proname: string
    province: string
    status:number
    tel: string
    time: string
    userid: string
  }
  interface IData {
    orderList: IOrderItem[],
    time: string
    express: number
  }
  export default defineComponent({
    data (): IData {
      return {
        orderList: [],
        time: '',
        express: 0
      }
    },
    computed: {
      totalPrice () {
        return this.orderList.reduce((sum, item) => {
          return sum += item.num * item.originprice
        }, 0)
      },
      flag () { // 显示地址
        return this.orderList[0]?.tel === ''
      }
    },
    mounted () {
      // console.log(this.$route)
      this.time = (this.$route.query.time as string)
      this.express = Math.floor(Math.random() * 20)

      const userid = localStorage.getItem('userid')!
      getOrderListData({ time: this.time, userid }).then(res => {
        console.log(res.data)
        this.orderList = res.data.data
      })
    },
    methods: {
      toAddressList () { // ++++++++++++
        this.$router.push('/orderAddress?time=' + this.time)
      }
    }
  })
</script>

<style lang="scss" scoped>
  .tip {
    margin-top: 15px;
    margin-right: 10px;
    text-align: right;
    p {
      display: flex;
      span {
        &:nth-child(1) {
          flex: 1;
        }
        &:nth-child(2) {
          width: 40px;
        }
      }
    }
  }
</style>
5.14.4 添加地址页面

cnpm i -S @vant/area-data

<!-- src/views/order/AddAddressView.vue -->
<template>
  <header class="header"><van-nav-bar
      title="添加地址"
      left-arrow
      @click-left="$router.back()"
    /></header>
  <div class="content">
    <van-address-edit
      :area-list="areaList"
      show-set-default
      :area-columns-placeholder="['请选择', '请选择', '请选择']"
      @save="onSave"
    />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { areaList } from '@vant/area-data';
  export default defineComponent({
    data () {
      return {
        areaList
      }
    },
    methods: {
      onSave (content: any) {
        console.log(content)
      }
    }
  })
</script>

<style lang="scss"></style>
// src/router/index.ts
// 1.引入 创建路由的模块 以及 路由历史记录模式的模块 
// 路由的历史模式:
// createWebHistory  HTML5模式  /home /kind /cart /user  需要后端配合
// createWebHashHistory Hash 模式  /#/home   /#/kind  /#/cart  /#/user
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, Router } from 'vue-router' // 路由规则的类型 ---- 路由规则的类型注解

// 2.引入页面组件
import Footer from '../components/FooterComponent.vue'
import HomeView from './../views/home/IndexView.vue'
import KindView from './../views/kind/IndexView.vue'
import CartView from './../views/cart/IndexView.vue'
import UserView from './../views/user/IndexView.vue'
import DetailView from '../views/detail/IndexView.vue'
import RegisterView from '../views/register/IndexView.vue'
import CheckTelComponent from '../views/register/components/CheckTelComponent.vue'
import SendTelCodeComponent from '../views/register/components/SendTelCodeComponent.vue'
import SetPasswordComponent from '../views/register/components/SetPasswordComponent.vue'
import LoginView from '../views/login/IndexView.vue'
import OrderView from '../views/order/IndexView.vue'
import OrderAddressListView from '../views/order/AddressListView.vue'
import OrderAddAddressView from '../views/order/AddAddressView.vue'

// 3.构建路由的规则
const routes: RouteRecordRaw[] = [
  { // 路由的重定向
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home', // 地址栏地址 - 路由
    name: 'home', // 命名路由  ---   唯一性
    // component: HomeView // 路由映射的页面组件
    components: {
      default: HomeView,
      footer: Footer
    }
  },
  {
    path: '/kind', 
    name: 'kind',
    // component: KindView 
    components: {
      default: KindView,
      footer: Footer
    }
  },
  {
    path: '/cart', 
    name: 'cart',
    // component: CartView 
    components: {
      default: CartView,
      footer: Footer
    }
  },
  {
    path: '/user', 
    name: 'user',
    // component: UserView 
    components: {
      default: UserView,
      footer: Footer
    }
  },
  {
    path: '/detail/:proid',
    name: 'detail',
    // component: DetailView
    components: {
      default: DetailView
    }
  },
  {
    path: '/register',
    name: 'register',
    redirect: '/register/index',
    component: RegisterView,
    children: [
      {
        path: 'index',
        component: CheckTelComponent
      },
      {
        path: 'sms',
        component: SendTelCodeComponent
      },
      {
        path: 'pwd',
        component: SetPasswordComponent
      }
    ]
  },
  {
    path: '/login',
    name: 'login',
    component: LoginView
  },
  {
    path: '/order',
    name: 'order',
    component: OrderView
  },
  {
    path: '/orderAddress',
    name: 'orderAddress',
    component: OrderAddressListView
  },
  { // +++++++++
    path: '/orderAddAddress',
    name: 'orderAddAddress',
    component: OrderAddAddressView
  }
]

// 4.生成路由
const router: Router = createRouter({
  // vue2 vue-router3 history: 'history' | 'hash' 
  history: createWebHistory(),
  routes // routes: routes 简写形式
})

// 5.暴露路由
export default router

添加并使用该地址(添加i地址进入地址列表页面,修改订单的地址)

5.14.5 封装地址相关接口
// src/api/address.ts
import request from '../service/request'
export interface IAddress {
  userid: string
  name: string
  tel: string
  province: string
  city: string
  county: string
  addressDetail: string
  isDefault: boolean
}
export function addAddRessData (params: IAddress ) {
  return request.post('/address/add', params)
}
export function getAddressList (params: {userid: string} ) {
  return request.get('/address/add', {params})
}

5.14.6 添加地址实现
<!-- src/views/order/AddAddressView.vue -->
<template>
  <header class="header"><van-nav-bar
      title="添加地址"
      left-arrow
      @click-left="$router.back()"
    /></header>
  <div class="content">
    <van-address-edit
      :area-list="areaList"
      show-set-default
      :area-columns-placeholder="['请选择', '请选择', '请选择']"
      @save="onSave"
    />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { areaList } from '@vant/area-data';
import { addAddRessData } from '@/api/address';
import { updateOrderAddressData } from '@/api/order';
  export default defineComponent({
    data () {
      return {
        areaList,
        time: ''
      }
    },
    mounted () {
      this.time = (this.$route.query.time as string)
    },
    methods: {
      onSave (content: any) {
        console.log(content)
        addAddRessData(content).then(res => {
          console.log(res.data) // 添加地址成功
          // 修改订单地址
          content.userid = localStorage.getItem('userid')!
          content.time = this.time
          updateOrderAddressData(content).then(res => {
            // 返回前两页  确认订单- 地址列表 - 新增地址
            this.$router.go(-2)
          })
        })
      }
    }
  })
</script>

<style lang="scss"></style>
5.14.7 确认订单页面处理地址

计算属性即可完成

<!-- src/views/order/IndexView.vue -->
<template>
  <header class="header">
    <van-nav-bar
      title="确定订单"
      left-arrow
      @click-left="$router.back()"
    />
  </header>
  <div class="content">
    <van-cell center v-if="flag" title="请添加地址" is-link @click="toAddressList" />
    <van-cell center v-else :title="name + ' ' +  tel" is-link :label="address"  @click="toAddressList"  />

    <van-card
      v-for="item of orderList"
      :key="item.orderid"
      :num="item.num"
      :price="item.originprice"
      :title="item.proname"
      :thumb="item.img1"
    />
    <div class="tip">
      <p><span>原价:</span><span>{{ totalPrice }}</span></p>
      <p><span>快递费:</span><span>{{ express }}</span></p>
    </div>
    <van-submit-bar :price="totalPrice * 100 + express * 100" button-text="去支付" />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { getOrderListData } from '../../api/order'
  interface IOrderItem {
    addressDetail: string
    city: string
    county: string
    discount: number
    img1: string
    name: string
    num: number
    orderid: string
    originprice: number
    proid: string
    proname: string
    province: string
    status:number
    tel: string
    time: string
    userid: string
  }
  interface IData {
    orderList: IOrderItem[],
    time: string
    express: number
  }
  export default defineComponent({
    data (): IData {
      return {
        orderList: [],
        time: '',
        express: 0
      }
    },
    computed: {
      totalPrice () {
        return this.orderList.reduce((sum, item) => {
          return sum += item.num * item.originprice
        }, 0)
      },
      flag () { // 显示地址
        return this.orderList[0]?.tel === ''
      },
      name () {
        return this.orderList[0]?.name
      },
      tel () {
        return this.orderList[0]?.tel
      },
e="item.proname"
      :thumb="item.img1"
    />
    <div class="tip">
      <p><span>原价:</span><span>{{ totalPrice }}</span></p>
      <p><span>快递费:</span><span>{{ express }}</span></p>
    </div>
    <van-submit-bar :price="totalPrice * 100 + express * 100" button-text="去支付" />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { getOrderListData } from '../../api/order'
  interface IOrderItem {
    addressDetail: string
    city: string
    county: string
    discount: number
    img1: string
    name: string
    num: number
    orderid: string
    originprice: number
    proid: string
    proname: string
    province: string
    status:number
    tel: string
    time: string
    userid: string
  }
  interface IData {
    orderList: IOrderItem[],
    time: string
    express: number
  }
  export default defineComponent({
    data (): IData {
      return {
        orderList: [],
        time: '',
        express: 0
      }
    },
    computed: {
      totalPrice () {
        return this.orderList.reduce((sum, item) => {
          return sum += item.num * item.originprice
        }, 0)
      },
      flag () { // 显示地址
        return this.orderList[0]?.tel === ''
      }
    },
    mounted () {
      // console.log(this.$route)
      this.time = (this.$route.query.time as string)
      this.express = Math.floor(Math.random() * 20)

      const userid = localStorage.getItem('userid')!
      getOrderListData({ time: this.time, userid }).then(res => {
        console.log(res.data)
        this.orderList = res.data.data
      })
    },
    methods: {
      toAddressList () { // ++++++++++++
        this.$router.push('/orderAddress?time=' + this.time)
      }
    }
  })
</script>

<style lang="scss" scoped>
  .tip {
    margin-top: 15px;
    margin-right: 10px;
    text-align: right;
    p {
      display: flex;
      span {
        &:nth-child(1) {
          flex: 1;
        }
        &:nth-child(2) {
          width: 40px;
        }
      }
    }
  }
</style>
5.14.4 添加地址页面

cnpm i -S @vant/area-data

<!-- src/views/order/AddAddressView.vue -->
<template>
  <header class="header"><van-nav-bar
      title="添加地址"
      left-arrow
      @click-left="$router.back()"
    /></header>
  <div class="content">
    <van-address-edit
      :area-list="areaList"
      show-set-default
      :area-columns-placeholder="['请选择', '请选择', '请选择']"
      @save="onSave"
    />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { areaList } from '@vant/area-data';
  export default defineComponent({
    data () {
      return {
        areaList
      }
    },
    methods: {
      onSave (content: any) {
        console.log(content)
      }
    }
  })
</script>

<style lang="scss"></style>
// src/router/index.ts
// 1.引入 创建路由的模块 以及 路由历史记录模式的模块 
// 路由的历史模式:
// createWebHistory  HTML5模式  /home /kind /cart /user  需要后端配合
// createWebHashHistory Hash 模式  /#/home   /#/kind  /#/cart  /#/user
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw, Router } from 'vue-router' // 路由规则的类型 ---- 路由规则的类型注解

// 2.引入页面组件
import Footer from '../components/FooterComponent.vue'
import HomeView from './../views/home/IndexView.vue'
import KindView from './../views/kind/IndexView.vue'
import CartView from './../views/cart/IndexView.vue'
import UserView from './../views/user/IndexView.vue'
import DetailView from '../views/detail/IndexView.vue'
import RegisterView from '../views/register/IndexView.vue'
import CheckTelComponent from '../views/register/components/CheckTelComponent.vue'
import SendTelCodeComponent from '../views/register/components/SendTelCodeComponent.vue'
import SetPasswordComponent from '../views/register/components/SetPasswordComponent.vue'
import LoginView from '../views/login/IndexView.vue'
import OrderView from '../views/order/IndexView.vue'
import OrderAddressListView from '../views/order/AddressListView.vue'
import OrderAddAddressView from '../views/order/AddAddressView.vue'

// 3.构建路由的规则
const routes: RouteRecordRaw[] = [
  { // 路由的重定向
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home', // 地址栏地址 - 路由
    name: 'home', // 命名路由  ---   唯一性
    // component: HomeView // 路由映射的页面组件
    components: {
      default: HomeView,
      footer: Footer
    }
  },
  {
    path: '/kind', 
    name: 'kind',
    // component: KindView 
    components: {
      default: KindView,
      footer: Footer
    }
  },
  {
    path: '/cart', 
    name: 'cart',
    // component: CartView 
    components: {
      default: CartView,
      footer: Footer
    }
  },
  {
    path: '/user', 
    name: 'user',
    // component: UserView 
    components: {
      default: UserView,
      footer: Footer
    }
  },
  {
    path: '/detail/:proid',
    name: 'detail',
    // component: DetailView
    components: {
      default: DetailView
    }
  },
  {
    path: '/register',
    name: 'register',
    redirect: '/register/index',
    component: RegisterView,
    children: [
      {
        path: 'index',
        component: CheckTelComponent
      },
      {
        path: 'sms',
        component: SendTelCodeComponent
      },
      {
        path: 'pwd',
        component: SetPasswordComponent
      }
    ]
  },
  {
    path: '/login',
    name: 'login',
    component: LoginView
  },
  {
    path: '/order',
    name: 'order',
    component: OrderView
  },
  {
    path: '/orderAddress',
    name: 'orderAddress',
    component: OrderAddressListView
  },
  { // +++++++++
    path: '/orderAddAddress',
    name: 'orderAddAddress',
    component: OrderAddAddressView
  }
]

// 4.生成路由
const router: Router = createRouter({
  // vue2 vue-router3 history: 'history' | 'hash' 
  history: createWebHistory(),
  routes // routes: routes 简写形式
})

// 5.暴露路由
export default router

添加并使用该地址(添加i地址进入地址列表页面,修改订单的地址)

5.14.5 封装地址相关接口
// src/api/address.ts
import request from '../service/request'
export interface IAddress {
  userid: string
  name: string
  tel: string
  province: string
  city: string
  county: string
  addressDetail: string
  isDefault: boolean
}
export function addAddRessData (params: IAddress ) {
  return request.post('/address/add', params)
}
export function getAddressList (params: {userid: string} ) {
  return request.get('/address/add', {params})
}

5.14.6 添加地址实现
<!-- src/views/order/AddAddressView.vue -->
<template>
  <header class="header"><van-nav-bar
      title="添加地址"
      left-arrow
      @click-left="$router.back()"
    /></header>
  <div class="content">
    <van-address-edit
      :area-list="areaList"
      show-set-default
      :area-columns-placeholder="['请选择', '请选择', '请选择']"
      @save="onSave"
    />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { areaList } from '@vant/area-data';
import { addAddRessData } from '@/api/address';
import { updateOrderAddressData } from '@/api/order';
  export default defineComponent({
    data () {
      return {
        areaList,
        time: ''
      }
    },
    mounted () {
      this.time = (this.$route.query.time as string)
    },
    methods: {
      onSave (content: any) {
        console.log(content)
        addAddRessData(content).then(res => {
          console.log(res.data) // 添加地址成功
          // 修改订单地址
          content.userid = localStorage.getItem('userid')!
          content.time = this.time
          updateOrderAddressData(content).then(res => {
            // 返回前两页  确认订单- 地址列表 - 新增地址
            this.$router.go(-2)
          })
        })
      }
    }
  })
</script>

<style lang="scss"></style>
5.14.7 确认订单页面处理地址

计算属性即可完成

<!-- src/views/order/IndexView.vue -->
<template>
  <header class="header">
    <van-nav-bar
      title="确定订单"
      left-arrow
      @click-left="$router.back()"
    />
  </header>
  <div class="content">
    <van-cell center v-if="flag" title="请添加地址" is-link @click="toAddressList" />
    <van-cell center v-else :title="name + ' ' +  tel" is-link :label="address"  @click="toAddressList"  />

    <van-card
      v-for="item of orderList"
      :key="item.orderid"
      :num="item.num"
      :price="item.originprice"
      :title="item.proname"
      :thumb="item.img1"
    />
    <div class="tip">
      <p><span>原价:</span><span>{{ totalPrice }}</span></p>
      <p><span>快递费:</span><span>{{ express }}</span></p>
    </div>
    <van-submit-bar :price="totalPrice * 100 + express * 100" button-text="去支付" />
  </div>
</template>

<script lang="ts">
  import { defineComponent } from 'vue';
  import { getOrderListData } from '../../api/order'
  interface IOrderItem {
    addressDetail: string
    city: string
    county: string
    discount: number
    img1: string
    name: string
    num: number
    orderid: string
    originprice: number
    proid: string
    proname: string
    province: string
    status:number
    tel: string
    time: string
    userid: string
  }
  interface IData {
    orderList: IOrderItem[],
    time: string
    express: number
  }
  export default defineComponent({
    data (): IData {
      return {
        orderList: [],
        time: '',
        express: 0
      }
    },
    computed: {
      totalPrice () {
        return this.orderList.reduce((sum, item) => {
          return sum += item.num * item.originprice
        }, 0)
      },
      flag () { // 显示地址
        return this.orderList[0]?.tel === ''
      },
      name () {
        return this.orderList[0]?.name
      },
      tel () {
        return this.orderList[0]?.tel
      },
      a
Logo

前往低代码交流专区

更多推荐