资源
正文
day01 Vue3 入门
02. 认识 Vue3
用 Vue2 写一个点击按钮自增:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <template> <button @click="addCount">{{ count }}</button> </template> <script> export default { data() { return { count: 0 } }, methods: { addCount() { this.count++ } } } </script>
用 Vue3 写一个点击按钮自增:
1 2 3 4 5 6 7 8 9 10 <template> <button @click="addCount">{{ count }}</button> </template> <script setup> import { ref } from 'vue'; const count = ref(0); const addCount = () => { count.value++; }; </script>
Vue3 与 Vue2 相比:代码量变少了,分散式维护转为集中式维护
Note
Vue3
更容易维护
组合式 API
更好的 TypeScript 支持
更快的速度
重写 diff 算法
模版编译优化
更高效的组件初始化
更小的体积
更优的数据响应式
03. 使用 create-vue 创建项目
Vue3 使用了新的脚手架工具:
create-vue 是 Vue 官方新的脚手架工具 ,底层切换到了 vite(下一代前端工具链) ,为开发提供极速响应。
执行命令:
安装之:
1 2 cd vue3-project npm install
运行之:
04. 熟悉项目目录和关键文件
关键文件:
vite.config.js
- 项目的配置文件 基于 vite 的配置
package.json
- 项目包文件 核心依赖项变成了 Vue3.x
和 vite
main.js
-入口文件 createApp
函数创建应用实例
app.vue
- 根组件 SFC 单文件组件 script-template-style
变化一:脚本 script 和模板 template 顺序调整
变化二:模板 template 不再要求唯一根元素
变化三:脚本 script 添加 setup 标识支持组合式 API
index.html
- 单页入口 提供 id 为 app 的挂载点
05. 组合式 API 入口-setup
如此编写代码:
1 2 3 4 5 6 7 8 9 10 11 <script setup> const message = 'this is a message'; const logMessage = () => { console.log(message); } </script> <template> {{ message }} <button @click="logMessage">Log Message</button> </template>
使用 setup 这一语法糖能够大大减少代码量:
06. 组合式 API-reactive 和 ref 函数
Note
reactive()
作用:接受 对象类型数据的参数传入 并返回一个响应式的对象
核心步骤:
1 2 3 4 5 6 <script setup> // 导入 import { reactive } from 'vue'; // 执行函数 传入函数 变量接收 const state = reactive(对象类型数据); </script>
从 vue 包中导入 reactive 函数
在 <script setup>
中执行 reactive 函数 并传入类型为对象 的初始值,并使用变量接收返回值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <script setup> import { reactive } from 'vue'; const state = reactive({ count: 0 }); const setCount = () => { state.count++; } </script> <template> <button @click="setCount()">{{ state.count }}</button> </template>
Note
ref()
作用:接收 简单类型或者对象类型的数据 传入并返回一个响应式的对象
核心步骤:
1 2 3 4 5 6 <script setup> // 导入 import { ref } from 'vue'; // 执行函数 传入函数 变量接收 const count = ref(对象类型数据); </script>
从 vue 包中导入 ref 函数
在 <script setup>
中执行 ref 函数 并传入初始值,使用变量接收 ref 函数的返回值
1 2 3 4 5 6 7 8 9 10 11 12 <script setup> import { ref } from 'vue'; const count = ref(0); const setCount = () => { count.value++; } </script> <template> <button @click="setCount()">{{ count }}</button> </template>
07. 组合式 API-computed
Note
computed 计算属性函数
计算属性基本思想和 Vue2 的完全一致,组合式 API 下的计算属性只是修改了写法
核心步骤:
导入 computed
函数
执行函数 在回调参数中 return
基于响应式数据做计算的值 ,用变量接收
1 2 3 4 5 6 7 8 <script setup> // 导入 import { computed } from 'vue'; // 执行函数 变量接受 在回调参数中 return 计算值 const computedState = computed(() => { return 基于响应式数据做计算之后的值; }) </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <script setup> import { ref, computed } from 'vue'; const list = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); const computedList = computed(() => { return list.value.filter((item) => item > 2); }) </script> <template> <div> 原始响应式数组 - {{ list }} <br> 计算响应式数组 - {{ computedList }} </div> </template>
1 2 原始响应式数组 - [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ] 计算响应式数组 - [ 3, 4, 5, 6, 7, 8, 9, 10 ]
08. 组合式 API-watch-基本使用和立即执行
Note
watch 函数
作用:侦听一个或者多个数据 的变化,数据变化时执行回调函数
俩个额外参数:
immediate(立即执行)
deep(深度侦听)
基础使用-侦听单个数据
导入 watch 函数
执行 watch 函数传入要侦听的响应式数据**(ref 对象)**和回调函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <script setup> import { ref, watch } from 'vue'; const count = ref(0); const setCount = () => { count.value++; } watch(count, (newVal, oldVal) => { console.log(`count 值改变了,新值:${newVal}, 旧值:${oldVal}`); }); </script> <template> <button @click="setCount">{{ count }}</button> </template>
Note
基础使用-侦听多个数据
说明:同时侦听多个响应式数据的变化,不管哪个数据变化都需要执行回调
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <script setup> import { ref, watch } from 'vue'; const count = ref(0); const name = ref('cp'); const setCount = () => { count.value++; } const setName = () => { name.value = 'pc'; } watch([count, name], ([newCount, newName], [oldCount, oldName]) => { console.log(`count: ${newCount}, name: ${newName}, oldCount: ${oldCount}, oldName: ${oldName}`); }); </script> <template> <button @click="setCount">{{ count }}</button> <button @click="setName">{{ name }}</button> </template>
Note
immediate
说明:在侦听器创建时立即触发回调 ,响应式数据变化之后继续执行回调
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <script setup> import { ref, watch } from 'vue'; const count = ref(0); watch(count, (newVal, oldVal) => { console.log(`count 值改变了,新值:${newVal}, 旧值:${oldVal}`); }, { immediate: true }); </script> <template> <div> 原始响应式数组 - {{ list }} <br> 计算响应式数组 - {{ computedList }} </div> </template>
1 count 值改变了,新值:0, 旧值:undefined
09. 组合式 API-watch-深度侦听和精确侦听
Note
deep
**默认机制:**通过 watch 监听的 ref 对象 默认是浅层侦听的,直接修改嵌套的对象属性不会触发回调执行,需要开启 deep
选项
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <script setup> import { ref, watch } from 'vue'; const state = ref({count: 0}); const changeStateByCount = () => { state.value.count++; } watch(state, () => { console.log('state changed: ', state.value.count); }, { deep: true }) </script> <template> <div> {{ state.count }} <button @click="changeStateByCount">+</button> </div> </template>
Note
精确侦听对象的某个属性
需求:在不开启 deep 的前提下,侦听 age 的变化,只有 age 变化时才执行回调
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <script setup> import { ref, watch } from 'vue'; const state = ref({name: 'chaichai', age: 18}); const changeName = () => { state.value.name = 'chaichai-teacher'; } const changeAge = () => { state.value.age = 20; } watch(() => state.value.age, () => { console.log('age changed'); }); </script> <template> <div> <div>当前 name -- {{ state.name }}</div> <div>当前 age -- {{ state.age }}</div> <div> <button @click="changeName">修改 name</button> <button @click="changeAge">修改 age</button> </div> </div> </template>
Note
作为 watch
函数的第一个参数,ref
对象需要添加 .value
吗?
不需要,watch 会自动读取
watch
只能侦听单个数据吗?
单个或者多个
不开启 deep
,直接修改嵌套属性能触发回调吗?
不能,默认是浅层侦听
不开启 deep
,想在某个层次比较深的属性变化时执行回调怎么做?
可以把第一个参数写成函数的写法,返回要监听的具体属性
10. 组合式 API-生命周期函数
Note
Vue3 的生命周期 API(选项式 VS 组合式)
选项式 API
组合式 API
功能
beforeCreate
/ created
setup
组件实例初始化阶段的钩子函数,beforeCreate
在组件实例创建之前触发,created
在实例创建后触发。可以在这里初始化数据或调用 API。
beforeMount
onBeforeMount
在虚拟 DOM 初次渲染完成并插入到页面之前触发,适合做一些即将渲染的 DOM 操作。
mounted
onMounted
在组件挂载到页面之后触发,常用于操作已经渲染的 DOM 或者发起网络请求。
beforeUpdate
onBeforeUpdate
在响应式数据更新并即将触发 DOM 更新前调用,可以在这里访问旧的 DOM 状态。
updated
onUpdated
在组件数据更新并重新渲染 DOM 后触发,可用于获取更新后的 DOM 状态。
beforeUnmount
onBeforeUnmount
在组件实例即将销毁之前调用,可用于清理事件监听器或定时器等资源。
unmounted
onUnmounted
在组件实例销毁之后调用,可用于进一步清理或日志记录。
生命周期函数基本使用
导入生命周期函数
执行生命周期函数 传入回调
执行多次
生命周期函数是可以执行多次的,多次执行时传入的回调会在时机成熟时依次执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <script setup> import { onMounted } from 'vue'; onMounted(() => { console.log('mounted', 1); }); onMounted(() => { console.log('mounted', 2); }); onMounted(() => { console.log('mounted', 3); }); </script> <template> <div> </div> </template>
1 2 3 mounted 1 mounted 2 mounted 3
组合式 API 中生命周期函数的格式是什么?
on + 生命周期名字
组合式 API 中可以使用 onCreated
吗?
没有这个钩子函数,直接写到 setup 中
组合式 API 中组件卸载完毕时执行哪个函数?
onUnmounted
11. 组合式 API 下的父子通信-父传子
Note
组合式 API 下的父传子
基本思想
父组件中给子组件绑定属性
1 2 3 4 5 6 7 8 9 10 11 12 <script setup> import { ref } from 'vue'; import SonCom from './components/son-com.vue'; const count = ref(100); </script> <template> <div class="father"> <h2>父组件 App</h2> <SonCom :count="count" message="来自父组件的消息"/> </div> </template>
子组件内部通过 props
选项接收
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <script setup> const props = defineProps({ message: String, count: Number }) </script> <template> <div class="son"> <h3>子组件</h3> <div> 父组件传入的数据 - {{ message }} - 父组件传入的计数 - {{ count }} </div> </div> </template>
1 2 3 父组件 App 子组件 父组件传入的数据 - 来自父组件的消息 - 父组件传入的计数 - 100
12. 组合式 API 下的父子通信-子传父
Note
组合式 API 下的子传父
基本思想
父组件中给子组件标签通过@绑定事件
1 2 3 4 5 6 7 8 9 10 <script setup> import SonCom from './components/son-com.vue'; const getMessage = (msg) => { console.log(msg); } </script> <template> <sonCom @get-message="getMessage"/> </template>
子组件内部通过 $emit 方法触发事件
1 2 3 4 5 6 7 8 9 10 <script setup> const emit = defineEmits(['get-message']); const sendMsg = () => { emit('get-message', 'hello'); } </script> <template> <button @click="sendMsg">sendMsg</button> </template>
Note
父传子
父传子的过程中通过什么方式接收props?
defineProps({属性名:类型})
setup 语法糖中如何使用父组件传过来的数据?
const props=defineProps({属性名: 类型})
子传父
子传父的过程中通过什么方式得到 emit 方法?
defineEmits(['事件名称'])
13. 组合式 API-模板引用
Note
模板引用的概念
通过 ref 标识 获取真实的 dom 对象或者组件实例对象
如何使用(以获取dom为例 组件同理)
调用 ref 函数生成一个 ref 对象
通过 ref 标识绑定 ref 对象到标签
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <script setup> import { onMounted, ref } from 'vue'; import SonCom from './components/son-com.vue'; const h1Ref = ref(null); const comRef = ref(null); onMounted(() => { console.log(h1Ref.value); console.log(comRef.value); }); </script> <template> <h1 ref="h1Ref">我是 dom 标签 h1</h1> <SonCom ref="comRef"/> </template>
defineExpose()
默认情况下在 <script setup>
语法糖下组件内部的属性和方法是不开放给父组件访问的,可以通过 defineExpose 编译宏指定哪些属性和方法允许访问 。
Note
获取模板引用的时机是什么?
组件挂载完毕
defineExpose 编译宏的作用是什么?
显式暴露组件内部的属性和方法
14. 组合式 API-provide 和 inject
Note
作用和场景
顶层组件向任意的底层组件传递数据和方法 ,实现跨层组件通信
跨层传递普通数据
顶层组件通过 provide 函数提供 数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <script setup> import { ref, provide } from 'vue'; import RoomMsgItem from './room-msg-item.vue'; // 组件嵌套关系: // RoomPage -> RoomMsgItem -> RoomMsgComment // 1. 顶层组件提供数据 provide('data-key', 'this is room data') // 2. 传递响应式数据 const count = ref(0); provide('count-key', count) </script> <template> <div>顶层组件<RoomMsgItem /></div> </template>
中层组件啥也不做
1 2 3 4 5 6 7 <script setup> import RoomMsgComment from './room-msg-comment.vue'; </script> <template> <div>中层组件<RoomMsgComment /></div> </template>
底层组件通过 inject 函数获取 数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <script setup> import { inject } from 'vue'; const roomData = inject('data-key'); const countData = inject('count-key'); </script> <template> <div class="comment"> 底层组件 <div> 来自顶层组件中的数据为:{{ roomData }} </div> <div> 来自顶层组件中的响应式数据:{{ countData }} </div> </div> </template>
1 2 3 4 5 顶层组件 中层组件 底层组件 来自顶层组件中的数据为:this is room data 来自顶层组件中的响应式数据:0
15-16. Vue3 综合小案例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 <script setup> import { onMounted, ref } from 'vue'; import axios from 'axios'; import Edit from './components/Edit.vue'; // TODO: 列表渲染 const tableData = ref([]); onMounted(() => { updateData(); }); const updateData = () => { axios.get('/list') .then(response => { tableData.value = response.data; }) .catch(error => { console.error('Error fetching data:', error); }); } // TODO: 删除功能 const handleDelete = (row) => { console.log(row); axios.delete(`/del/${row.id}`); updateData(); }; // TODO: 编辑功能 const edit = ref(null); const handleEdit = (row) => { console.log(row) edit.value.openDialog(row); }; </script> <template> <div class="app"> <el-table :data="tableData"> <el-table-column label="ID" prop="id"></el-table-column> <el-table-column label="姓名" prop="name" width="150"></el-table-column> <el-table-column label="籍贯" prop="place"></el-table-column> <el-table-column label="操作" width="150"> <template #default="{ row }"> <el-button type="primary" @click="handleEdit(row)" link>编辑</el-button> <el-button type="danger" @click="handleDelete(row)" link>删除</el-button> </template> </el-table-column> </el-table> </div> <Edit ref="edit" @get-message="updateData" /> </template> <style scoped> .app { width: 980px; margin: 100px auto 0; } </style>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 <script setup> // TODO: 编辑 import { ref } from 'vue' // 弹框开关 const dialogVisible = ref(false) const form = ref({ id: '', name: '', place: '' }); const openDialog = (data) => { console.log('open dialog') form.value = data; dialogVisible.value = true; }; defineExpose({ openDialog }); const emit = defineEmits(['get-message']); const onConfirm = () => { console.log('onConfirm', form.value); dialogVisible.value = false; axios.patch(`/edit/${form.value.id}`, { name: form.value.name, place: form.value.place, }) emit('get-message'); } </script> <template> <el-dialog v-model="dialogVisible" title="编辑" width="400px"> <el-form label-width="50px"> <el-form-item label="姓名"> <el-input v-model="form.name" placeholder="请输入姓名" /> </el-form-item> <el-form-item label="籍贯"> <el-input v-model="form.place" placeholder="请输入籍贯" /> </el-form-item> </el-form> <template #footer> <span class="dialog-footer"> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" @click="onConfirm">确认</el-button> </span> </template> </el-dialog> </template> <style scoped> .el-input { width: 290px; } </style>
day02
01. Pinia-添加 pinia 到 vue 项目
Note
什么是 Pinia
Pinia 是 Vue 的专属的最新状态管理库 ,是 Vuex 状态管理工具的替代品
提供更加简单的 API(去掉了 mutation)
提供符合组合式风格的 API(和 Vue3 新语法统一)
去掉了 modules 的概念,每一个 store 都是一个独立的模块
搭配 TypeScript 一起使用提供可靠的类型推断
根据 开始 | Pinia 里的教程给一个 vue 项目添加 pinia。
创建一个 pinia 实例 (根 store) 并将其传递给应用(修改 main.js
):
1 2 3 4 5 6 7 8 9 10 11 import './assets/main.css' import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const pinia = createPinia ()const app = createApp (App ) app.use (pinia) app.mount ('#app' )
02. Pinia-counter 基础使用
定义 store(创建 stores/counter.js
):
1 2 3 4 5 6 7 8 9 10 11 12 import { defineStore } from 'pinia' export const useCounterStore = defineStore ('counter' , { state : () => { return { count : 0 } }, actions : { increment ( ) { this .count ++ } } })
App.vue
中使用 store:
1 2 3 4 5 6 7 8 9 <script setup> import { useCounterStore } from './stores/counter'; const counter = useCounterStore(); </script> <template> <button @click="counter.increment()">{{ counter.count }}</button> </template>
03. Pinia-getters 和异步 action
编辑 stores/counter.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import { defineStore } from 'pinia' import { computed, ref } from 'vue' import axios from 'axios' ;const API_URL = 'http://geek.itheima.net/v1_0/channels' ;export const useCounterStore = defineStore ('counter' , () => { const count = ref (0 ); const increment = ( ) => { count.value ++; } const doubleCount = computed (() => count.value * 2 ); const list = ref ([]); const getList = async ( ) => { const res = await axios.get (API_URL ); console .log (res); list.value = res.data .data .channels ; } return { count, increment, doubleCount, getList, list } })
App.vue
中使用 v-for
渲染经 axios
获得的列表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <script setup> import { onMounted } from 'vue'; import { useCounterStore } from './stores/counter'; const counter = useCounterStore(); onMounted(() => { counter.getList(); console.log(counter.list.value) }); </script> <template> <button @click="counter.increment()">{{ counter.count }}</button> <div>{{ counter.doubleCount }}</div> <li v-for="item in counter.list" :key="item.id">{{ item.name }}</li> </template>
04. Pinia-storeToRefs 和调试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <script setup> import { onMounted } from 'vue'; import { useCounterStore } from './stores/counter'; import { storeToRefs } from 'pinia'; const counter = useCounterStore(); const {count, doubleCount, list} = storeToRefs(counter); const { getList, increment } = counter; onMounted(() => { getList(); }); </script> <template> <button @click="increment()">{{ count }}</button> <div>{{ doubleCount }}</div> <li v-for="item in list" :key="item.id">{{ item.name }}</li> </template>
05. 项目起步-项目初始化和 git 管理
以此法创建一个 vue-rabbit
项目。
调整 src 目录:
src
apis
:API 接口文件夹
assets
components
composables
:组合函数文件夹
directives
:全局指令文件夹
router
stores
styles
:全局样式文件夹
utils
:工具函数文件夹
views
接着使用 git 管理项目
基于 create-vue
创建出来的项目默认没有初始化 git 仓库,需要我们手动初始化
执行命令并完成首次提交
git init
git add .
git commit -m “init"
06. 项目起步-别名路径联想设置
Note
什么是别名路径联想提示
在编写代码的过程中,一旦 输入 @/ ,VSCode 会立刻 联想出 src 下的所有子目录和文件 ,统一文件路径访问不容易出错。
如何进行配置
在项目的根目录下 新增 jsconfig.json
文件
添加 json 格式的配置项,如下:
1 2 3 4 5 6 7 { "compilerOptions" : { "paths" : { "@/*" : [ "./src/*" ] } } }
配置完成:
提交修改:
1 2 git add . git commit -m "完成别名联想设置"
07. 项目起步-elementPlus 自动按需导入配置
Note
小兔鲜项目的组件分类
小兔鲜项目 UI 组件
通用型组件(ElementPlus):Dialog 模态框
业务定制化组件(手写):商品热榜组件
添加 ElementPlus 到项目(按需导入)
看文档 快速开始 | Element Plus
安装
1 npm install element-plus --save
配置按需引入
1 npm install -D unplugin-vue-components unplugin-auto-import
配置 vite.config.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig ({ plugins : [ vue (), vueDevTools (), AutoImport ({ resolvers : [ElementPlusResolver ()], }), Components ({ resolvers : [ElementPlusResolver ()], }), ], resolve : { alias : { '@' : fileURLToPath (new URL ('./src' , import .meta .url )) }, }, })
测试组件
08. 项目起步-elementPlus 主题色定制
Note
为什么需要主题定制
小免鲜主题色和 elementPlus 默认的主题色存在冲突,通过定制主题让 elementPlus 的主题色和小免鲜项目保持一致
如何定制(scss 变量替换方案)
09. 项目起步-axios 基础配置
Note
axios 基础配置
安装 axios
配置基础实例(统一接口配置)
utils/http.js
中创建 axios 实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import axios from 'axios' ; const http = axios.create ({ baseURL : 'http://pcapi-xiaotuxian-front-devtest.itheima.net' , timeout : 5000 }); http.interceptors .request .use (config => { return config; }, error => { return Promise .reject (error); }); http.interceptors .response .use (response => response.result { return response; }, error => { return Promise .reject (error); }); export default http;
apis/testAPI.js
中创建 API:
1 2 3 4 5 6 7 import httpInstance from '@/utils/http' ; export function getCategory ( ) { return httpInstance ({ url : 'home/category/head' }) }
main.js
中测试接口函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import { getCategory } from '@/apis/testAPI' ;getCategory ().then (res => { console .log (res); }); const app = createApp (App ) app.use (createPinia ()) app.use (router) app.mount ('#app' )
如果项目里面不同的业务模块需要的接口基地址不同,该如何来做?
axios.create()
方法可以执行多次,每次执行就会生成一个新的实例,比如:
1 2 const http1 = axios.create ({ baseURL : 'url1' })const http2 = axios.create ({ baseURL : 'url2' })
10. 项目起步-项目整体路由设计
Note
设计首页和登录页的路由(一级路由)
路由设计原则:找内容切换的区域,如果是页面整体切换 ,则为一级路由
创建:
App.vue
:
1 2 3 4 5 6 7 <script setup> </script> <template> <RouterView/> </template>
views
Category
index.vue
1 2 3 <template> <div>我是 Category 页</div> </template>
Home
index.vue
1 2 3 <template> <div>我是 Home 页</div> </template>
Layout
index.vue
1 2 3 4 <template> <div>我是首页</div> <RouterView/> </template>
Login
index.vue
1 2 3 <template> <div>我是登录页</div> </template>
配置路由 router/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import { createRouter, createWebHistory } from 'vue-router' import Layout from '@/views/Layout/index.vue' import Login from '@/views/Login/index.vue' import Home from '@/views/Home/index.vue' import Category from '@/views/Category/index.vue' const router = createRouter ({ history : createWebHistory (import .meta .env .BASE_URL ), routes : [ { path : '/' , name : 'home' , component : Layout , children : [ { path : '' , component : Home }, { path : 'category' , component : Category } ] }, { path : '/login' , component : Login }, ], })export default router
此时服务器 http://localhost:5173/
:
http://localhost:5173/login
:
http://localhost:5173/category
:
11. 项目起步-静态资源引入和 ErrorLen 安装
Note
图片资源和样式资源
资源说明
实际工作中的图片资源通常由 UI 设计师 提供,常见的图片格式有 png, svg 等都是由 UI 切图交给前端
样式资源通常是指项目初始化的时候进行样式重置,常见的比如开源的 normalize.css 或者手写
资源操作
图片资源-把 images 文件夹放到 assets 目录下
样式资源-把 common.scss 文件放到 styles 目录下
然后在 main.js
中引入:
1 import '@/styles/common.scss'
然后推荐安装 error lens 插件。
13. Layout-静态模版结构搭建
配置好:
views
Layout
index.vue
1 2 3 4 5 6 7 8 9 10 11 12 <script setup> import LayoutNav from './components/LayoutNav.vue' import LayoutHeader from './components/LayoutHeader.vue' import LayoutFooter from './components/LayoutFooter.vue' </script> <template> <LayoutNav /> <LayoutHeader /> <RouterView /> <LayoutFooter /> </template>
components
LayoutFooter.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 <template> <footer class="app_footer"> <!-- 联系我们 --> <div class="contact"> <div class="container"> <dl> <dt>客户服务</dt> <dd><i class="iconfont icon-kefu"></i> 在线客服</dd> <dd><i class="iconfont icon-question"></i> 问题反馈</dd> </dl> <dl> <dt>关注我们</dt> <dd><i class="iconfont icon-weixin"></i> 公众号</dd> <dd><i class="iconfont icon-weibo"></i> 微博</dd> </dl> <dl> <dt>下载APP</dt> <dd class="qrcode"><img src="@/assets/images/qrcode.jpg" /></dd> <dd class="download"> <span>扫描二维码</span> <span>立马下载APP</span> <a href="javascript:;">下载页面</a> </dd> </dl> <dl> <dt>服务热线</dt> <dd class="hotline">400-0000-000 <small>周一至周日 8:00-18:00</small></dd> </dl> </div> </div> <!-- 其它 --> <div class="extra"> <div class="container"> <div class="slogan"> <a href="javascript:;"> <i class="iconfont icon-footer01"></i> <span>价格亲民</span> </a> <a href="javascript:;"> <i class="iconfont icon-footer02"></i> <span>物流快捷</span> </a> <a href="javascript:;"> <i class="iconfont icon-footer03"></i> <span>品质新鲜</span> </a> </div> <!-- 版权信息 --> <div class="copyright"> <p> <a href="javascript:;">关于我们</a> <a href="javascript:;">帮助中心</a> <a href="javascript:;">售后服务</a> <a href="javascript:;">配送与验收</a> <a href="javascript:;">商务合作</a> <a href="javascript:;">搜索推荐</a> <a href="javascript:;">友情链接</a> </p> <p>CopyRight © 小兔鲜儿</p> </div> </div> </div> </footer> </template> <style scoped lang='scss'> .app_footer { overflow: hidden; background-color: #f5f5f5; padding-top: 20px; .contact { background: #fff; .container { padding: 60px 0 40px 25px; display: flex; } dl { height: 190px; text-align: center; padding: 0 72px; border-right: 1px solid #f2f2f2; color: #999; &:first-child { padding-left: 0; } &:last-child { border-right: none; padding-right: 0; } } dt { line-height: 1; font-size: 18px; } dd { margin: 36px 12px 0 0; float: left; width: 92px; height: 92px; padding-top: 10px; border: 1px solid #ededed; .iconfont { font-size: 36px; display: block; color: #666; } &:hover { .iconfont { color: $xtxColor; } } &:last-child { margin-right: 0; } } .qrcode { width: 92px; height: 92px; padding: 7px; border: 1px solid #ededed; } .download { padding-top: 5px; font-size: 14px; width: auto; height: auto; border: none; span { display: block; } a { display: block; line-height: 1; padding: 10px 25px; margin-top: 5px; color: #fff; border-radius: 2px; background-color: $xtxColor; } } .hotline { padding-top: 20px; font-size: 22px; color: #666; width: auto; height: auto; border: none; small { display: block; font-size: 15px; color: #999; } } } .extra { background-color: #333; } .slogan { height: 178px; line-height: 58px; padding: 60px 100px; border-bottom: 1px solid #434343; display: flex; justify-content: space-between; a { height: 58px; line-height: 58px; color: #fff; font-size: 28px; i { font-size: 50px; vertical-align: middle; margin-right: 10px; font-weight: 100; } span { vertical-align: middle; text-shadow: 0 0 1px #333; } } } .copyright { height: 170px; padding-top: 40px; text-align: center; color: #999; font-size: 15px; p { line-height: 1; margin-bottom: 20px; } a { color: #999; line-height: 1; padding: 0 10px; border-right: 1px solid #999; &:last-child { border-right: none; } } } } </style>
LayoutHeader.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 <script setup> </script> <template> <header class='app-header'> <div class="container"> <h1 class="logo"> <RouterLink to="/">小兔鲜</RouterLink> </h1> <ul class="app-header-nav"> <li class="home"> <RouterLink to="/">首页</RouterLink> </li> <li> <RouterLink to="/">居家</RouterLink> </li> <li> <RouterLink to="/">美食</RouterLink> </li> <li> <RouterLink to="/">服饰</RouterLink> </li> </ul> <div class="search"> <i class="iconfont icon-search"></i> <input type="text" placeholder="搜一搜"> </div> <!-- 头部购物车 --> </div> </header> </template> <style scoped lang='scss'> .app-header { background: #fff; .container { display: flex; align-items: center; } .logo { width: 200px; a { display: block; height: 132px; width: 100%; text-indent: -9999px; background: url('@/assets/images/logo.png') no-repeat center 18px / contain; } } .app-header-nav { width: 820px; display: flex; padding-left: 40px; position: relative; z-index: 998; li { margin-right: 40px; width: 38px; text-align: center; a { font-size: 16px; line-height: 32px; height: 32px; display: inline-block; &:hover { color: $xtxColor; border-bottom: 1px solid $xtxColor; } } .active { color: $xtxColor; border-bottom: 1px solid $xtxColor; } } } .search { width: 170px; height: 32px; position: relative; border-bottom: 1px solid #e7e7e7; line-height: 32px; .icon-search { font-size: 18px; margin-left: 5px; } input { width: 140px; padding-left: 5px; color: #666; } } .cart { width: 50px; .curr { height: 32px; line-height: 32px; text-align: center; position: relative; display: block; .icon-cart { font-size: 22px; } em { font-style: normal; position: absolute; right: 0; top: 0; padding: 1px 6px; line-height: 1; background: $helpColor; color: #fff; font-size: 12px; border-radius: 10px; font-family: Arial; } } } } </style>
LayoutNav.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 <script setup> </script> <template> <nav class="app-topnav"> <div class="container"> <ul> <template v-if="true"> <li><a href="javascript:;"><i class="iconfont icon-user"></i>周杰伦</a></li> <li> <el-popconfirm title="确认退出吗?" confirm-button-text="确认" cancel-button-text="取消"> <template #reference> <a href="javascript:;">退出登录</a> </template> </el-popconfirm> </li> <li><a href="javascript:;">我的订单</a></li> <li><a href="javascript:;">会员中心</a></li> </template> <template v-else> <li><a href="javascript:;">请先登录</a></li> <li><a href="javascript:;">帮助中心</a></li> <li><a href="javascript:;">关于我们</a></li> </template> </ul> </div> </nav> </template> <style scoped lang="scss"> .app-topnav { background: #333; ul { display: flex; height: 53px; justify-content: flex-end; align-items: center; li { a { padding: 0 15px; color: #cdcdcd; line-height: 1; display: inline-block; i { font-size: 14px; margin-right: 2px; } &:hover { color: $xtxColor; } } ~li { a { border-left: 2px solid #666; } } } } } </style>
14. Layout-字体图标引入
Note
如何引入
阿里的字体图标库支持多种引入方式,小免鲜项目里采用的是 font-class 引用 的方式,index.html
下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <!DOCTYPE html > <html lang ="" > <head > <meta charset ="UTF-8" > <link rel ="icon" href ="/favicon.ico" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <link rel ="stylesheet" href ="//at.alicdn.com/t/font_2143783_iq6z4ey5vu.css" > <title > Vite App</title > </head > <body > <div id ="app" > </div > <script type ="module" src ="/src/main.js" > </script > </body > </html >
//at.alicdn.com/t/font_2143783_iq6z4ey5vu.css
这个地址的创建方法:
15. Layout-一级导航渲染
Note
功能描述
使用后端接口渲染渲染一级路由导航
实现步骤
根据接口文档封装接口函数
发送请求获取数据列表
v-for
渲染页面
编辑 apis/layout.js
:
1 2 3 4 5 6 7 import httpInstance from '@/utils/http' ;export function getCategoryAPI ( ) { return httpInstance ({ url : 'home/category/head' }) }
使用 v-for
将接收到的数据渲染到 LayoutHeader.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 <script setup> import { getCategoryAPI } from '@/apis/testAPI'; import { onMounted, ref } from 'vue'; const categoryList = ref([]); const getCategory = async () => { const res = await getCategoryAPI(); categoryList.value = res.result; } onMounted(() => { getCategory(); }); </script> <template> <header class='app-header'> <div class="container"> <h1 class="logo"> <RouterLink to="/">小兔鲜</RouterLink> </h1> <ul class="app-header-nav"> <li class="home" v-for="item in categoryList" :key="item.id"> <RouterLink to="/">{{ item.name }}</RouterLink> </li> </ul> <div class="search"> <i class="iconfont icon-search"></i> <input type="text" placeholder="搜一搜"> </div> <!-- 头部购物车 --> </div> </header> </template>
移除 main.js
中先前测试 axios 部分的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import '@/styles/common.scss' const app = createApp (App ) app.use (createPinia ()) app.use (router) app.mount ('#app' )
16. Layout-吸顶导航交互实现
Note
吸顶交互
要求:浏览器在上下滚动的过程中,如果距离顶部的滚动距离大于 78px,吸顶导航显示,小于 78px 隐藏
17. Layout-Pinia 优化重复请求
Note
从 pinia 中获取数据,分发给各个组件。
创建 stores/category.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import { ref } from 'vue' ;import { defineStore } from 'pinia' ;import { getCategoryAPI } from '@/apis/layout' ;export const useCategoryStore = defineStore ('category' , () => { const categoryList = ref ([]); const getCategory = async ( ) => { const res = await getCategoryAPI (); console .log (res); categoryList.value = res.result ; } return { categoryList, getCategory }; });
Layout/index.vue
中通过 onMounted()
获取数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <script setup> import LayoutNav from './components/LayoutNav.vue' import LayoutHeader from './components/LayoutHeader.vue' import LayoutFooter from './components/LayoutFooter.vue' import LayoutFixed from './components/LayoutFixed.vue'; import { useCategoryStore } from '@/stores/category'; import { onMounted } from 'vue'; const categoryStore = useCategoryStore(); onMounted(()=> { categoryStore.getCategory(); console.log(categoryStore) }); </script> <template> <LayoutFixed /> <LayoutNav /> <LayoutHeader /> <RouterView /> <LayoutFooter /> </template>
Layout/components/LayoutFixed.vue Layout/components/LayoutHeader.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 <script setup> import { useScroll } from '@vueuse/core'; import { useCategoryStore } from '@/stores/category'; const { y } = useScroll(window); const categoryStore = useCategoryStore(); </script> <template> <div class="app-header-sticky" :class="{ show: y > 78 }"> <div class="container"> <RouterLink class="logo" to="/" /> <!-- 导航区域 --> <ul class="app-header-nav "> <li class="home"> <RouterLink to="/">首页</RouterLink> </li> <li class="home" v-for="item in categoryStore.categoryList " :key="item.id"> <RouterLink to="/">{{ item.name }}</RouterLink> </li> </ul> <div class="right"> <RouterLink to="/">品牌</RouterLink> <RouterLink to="/">专题</RouterLink> </div> </div> </div> </template>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <script setup> import { useCategoryStore } from '@/stores/category'; const categoryStore = useCategoryStore(); </script> <template> <header class='app-header'> <div class="container"> <h1 class="logo"> <RouterLink to="/">小兔鲜</RouterLink> </h1> <ul class="app-header-nav"> <li class="home" v-for="item in categoryStore.categoryList " :key="item.id"> <RouterLink to="/">{{ item.name }}</RouterLink> </li> </ul> <div class="search"> <i class="iconfont icon-search"></i> <input type="text" placeholder="搜一搜"> </div> <!-- 头部购物车 --> </div> </header> </template>
day03
01. Home-整体结构拆分和分类实现
分析主页结构:
创建如下文件:
Home
index.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <script setup> import HomeCategory from './components/HomeCategory.vue' import HomeBanner from './components/HomeBanner.vue' import HomeNew from './components/HomeNew.vue' import HomeHot from './components/HomeHot.vue' import homeProduct from './components/HomeProduct.vue' </script> <template> <div class="container"> <HomeCategory /> <HomeBanner /> </div> <HomeNew /> <HomeHot /> <homeProduct /> </template>
components
HomeBanner.vue
HomeCategory.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 <script setup> </script> <template> <div class="home-category"> <ul class="menu"> <li v-for="item in 9" :key="item"> <RouterLink to="/">居家</RouterLink> <RouterLink v-for="i in 2" :key="i" to="/">南北干货</RouterLink> <!-- 弹层layer位置 --> <div class="layer"> <h4>分类推荐 <small>根据您的购买或浏览记录推荐</small></h4> <ul> <li v-for="i in 5" :key="i"> <RouterLink to="/"> <img alt="" /> <div class="info"> <p class="name ellipsis-2"> 男士外套 </p> <p class="desc ellipsis">男士外套,冬季必选</p> <p class="price"><i>¥</i>200.00</p> </div> </RouterLink> </li> </ul> </div> </li> </ul> </div> </template> <style scoped lang='scss'> .home-category { width: 250px; height: 500px; background: rgba(0, 0, 0, 0.8); position: relative; z-index: 99; .menu { li { padding-left: 40px; height: 55px; line-height: 55px; &:hover { background: $xtxColor; } a { margin-right: 4px; color: #fff; &:first-child { font-size: 16px; } } .layer { width: 990px; height: 500px; background: rgba(255, 255, 255, 0.8); position: absolute; left: 250px; top: 0; display: none; padding: 0 15px; h4 { font-size: 20px; font-weight: normal; line-height: 80px; small { font-size: 16px; color: #666; } } ul { display: flex; flex-wrap: wrap; li { width: 310px; height: 120px; margin-right: 15px; margin-bottom: 15px; border: 1px solid #eee; border-radius: 4px; background: #fff; &:nth-child(3n) { margin-right: 0; } a { display: flex; width: 100%; height: 100%; align-items: center; padding: 10px; &:hover { background: #e3f9f4; } img { width: 95px; height: 95px; } .info { padding-left: 10px; line-height: 24px; overflow: hidden; .name { font-size: 16px; color: #666; } .desc { color: #999; } .price { font-size: 22px; color: $priceColor; i { font-size: 16px; } } } } } } } // 关键样式 hover状态下的layer盒子变成block &:hover { .layer { display: block; } } } } } </style>
HomeHot.vue
HomeNew.vue
HomeProduct.vue
分类实现
准备模板 → 使用 Pinia 中的数据渲染
分析 Pinia 中获得的数据,设计 HomeCategory.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 <script setup> import { useCategoryStore } from '@/stores/category'; const categoryStore = useCategoryStore(); console.log("categoryStore.categoryList", categoryStore.categoryList); </script> <template> <div class="home-category"> <ul class="menu"> <li v-for="item in categoryStore.categoryList" :key="item.id"> <RouterLink to="/">{{ item.name }}</RouterLink> <RouterLink v-for="i in item.children.slice(0, 2)" :key="i" to="/">{{ i.name }}</RouterLink> <!-- 弹层layer位置 --> <div class="layer"> <h4>分类推荐 <small>根据您的购买或浏览记录推荐</small></h4> <ul> <li v-for="i in item.goods" :key="i.id"> <RouterLink to="/"> <img :src="i.picture" /> <div class="info"> <p class="name ellipsis-2"> {{ i.name }} </p> <p class="desc ellipsis">{{ i.desc}}</p> <p class="price"><i>¥</i>{{ i.price }}</p> </div> </RouterLink> </li> </ul> </div> </li> </ul> </div> </template>
02. Home-banner 轮播图实现
轮播图实现
03. Home-面板组件封装
Note
场景说明
问:组件封装解决了什么问题?
答:1. 复用问题 2. 业务维护问题
新鲜好物和人气推荐模块,在结构上非常相似 ,只是内容不同,通过组件封装可以实现复用结构 的效果
组件封装
核心思路:把可复用的结构只写一次,把可能发生变化的部分抽象成组件参数(props/插槽)
实现步骤
不做任何抽象,准备静态模版
创建一个 Home/components/HomePanel.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 <script setup> </script> <template> <div class="home-panel"> <div class="container"> <div class="head"> <!-- 主标题和副标题 --> <h3> 新鲜好物<small>新鲜出炉 品质靠谱</small> </h3> </div> <!-- 主体内容区域 --> <div> 主体内容 </div> </div> </div> </template> <style scoped lang='scss'> .home-panel { background-color: #fff; .head { padding: 40px 0; display: flex; align-items: flex-end; h3 { flex: 1; font-size: 32px; font-weight: normal; margin-left: 6px; height: 35px; line-height: 35px; small { font-size: 16px; color: #999; margin-left: 20px; } } } } </style>
抽象可变的部分
主标题和副标题是纯文本 ,可以抽象成 prop 传入
主体内容是复杂的模版 ,抽象成插槽传入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 <script setup> defineProps({ title: { type: String, }, subTitle: { type: String, } }); </script> <template> <div class="home-panel"> <div class="container"> <div class="head"> <!-- 主标题和副标题 --> <h3> {{ title }}<small>{{ subTitle}}</small> </h3> </div> <!-- 主体内容区域 --> <slot/> </div> </div> </template>
使用这个面板组件(Home/index.vue
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <script setup> import HomeCategory from './components/HomeCategory.vue' import HomeBanner from './components/HomeBanner.vue' import HomeNew from './components/HomeNew.vue' import HomeHot from './components/HomeHot.vue' import homeProduct from './components/HomeProduct.vue' import HomePanel from './components/HomePanel.vue' </script> <template> <div class="container"> <HomeCategory /> <HomeBanner /> </div> <HomeNew /> <HomeHot /> <homeProduct /> <HomePanel title="新鲜好物"> <div>我是新鲜好物的插槽内容</div> </HomePanel> <HomePanel title="人气推荐"> <div>我是人气推荐的插槽内容</div> </HomePanel> </template>
04. Home-新鲜好物业务实现
准备模板(HomePanel 组件)
HomeNew.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 <script setup> </script> <template> <div></div> <!-- 下面是插槽主体内容模版 <ul class="goods-list"> <li v-for="item in newList" :key="item.id"> <RouterLink to="/"> <img :src="item.picture" alt="" /> <p class="name">{{ item.name }}</p> <p class="price">¥{{ item.price }}</p> </RouterLink> </li> </ul> --> </template> <style scoped lang='scss'> .goods-list { display: flex; justify-content: space-between; height: 406px; li { width: 306px; height: 406px; background: #f0f9f4; transition: all .5s; &:hover { transform: translate3d(0, -3px, 0); box-shadow: 0 3px 8px rgb(0 0 0 / 20%); } img { width: 306px; height: 306px; } p { font-size: 22px; padding-top: 12px; text-align: center; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } .price { color: $priceColor; } } } </style>
设置 apis/home.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import httpInstance from '@/utils/http' ;export function getBannerAPI ( ) { return httpInstance ({ url : 'home/banner' }) }export const findNewAPI = ( ) => { return httpInstance ({ url : 'home/new' }); }
使用这个 API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <script setup> import HomePanel from './HomePanel.vue'; import { findNewAPI } from '@/apis/home'; import { onMounted, ref } from 'vue'; const newList = ref([]); const getNewList = async () => { const res = await findNewAPI(); newList.value = res.result; console.log("res", res); } onMounted(() => { getNewList(); }); </script> <template> <HomePanel title="新品推荐" sub-title="新鲜出炉 品质靠谱"> <ul class="goods-list"> <li v-for="item in newList" :key="item.id"> <RouterLink to="/"> <img :src="item.picture" alt="" /> <p class="name">{{ item.name }}</p> <p class="price">¥{{ item.price }}</p> </RouterLink> </li> </ul> </HomePanel> </template>
05. Home-图片懒加载指令实现
Note
场景和指令用法
场景:电商网站的首页通常会很长,用户不一定能访问到页面靠下面的图片 ,这类图片通过懒加载优化手段可以做到只有进入视口区域才发送图片请求 。
指令用法:<img v-img-lazy="item.picture"/>
在图片 img 身上绑定指令,该图片只有在正式进入到视口区域时才会发送图片网络请求。
实现思路和步骤
核心原理:图片进入视口才发送资源请求
熟悉指令语法
自定义指令 | Vue.js
在 main.js
中注册这个指令:
1 2 3 4 5 6 app.directive ('img-lazy' , { mounted (el, binding ) { console .log (el, binding) } })
apis/home.js
中设置 API:
1 2 3 4 5 export const getHotAPI = ( ) => { return httpInstance ({ url : 'home/hot' }); }
在 Home/components/HomeHot.vue
中使用 v-img-lazy
这个自定义指令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 <script setup> import HomePanel from './HomePanel.vue' import { getHotAPI } from '@/apis/home' import { onMounted, ref } from 'vue' const hotList = ref([]) const getHotList = async () => { const res = await getHotAPI() hotList.value = res.result } onMounted(() => { getHotList() }); </script> <template> <HomePanel title="人气推荐" sub-title="人气爆款 不容错过"> <ul class="goods-list"> <li v-for="item in hotList" :key="item.id"> <RouterLink to="/"> <img v-img-lazy="item.picture" alt=""> <p class="name">{{ item.title }}</p> <p class="desc">{{ item.alt }}</p> </RouterLink> </li> </ul> </HomePanel> </template> <style scoped lang='scss'> .goods-list { display: flex; justify-content: space-between; height: 426px; li { width: 306px; height: 406px; transition: all .5s; &:hover { transform: translate3d(0, -3px, 0); box-shadow: 0 3px 8px rgb(0 0 0 / 20%); } img { width: 306px; height: 306px; } p { font-size: 22px; padding-top: 12px; text-align: center; } .desc { color: #999; font-size: 18px; } } } </style>
判断图片是否进入视口(vueUse
)
测试图片监控是否生效
如果图片进入视口,发送图片资源请(img.src=url
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 app.directive ('img-lazy' , { mounted (el, binding ) { console .log (el, binding); useIntersectionObserver (el, ([{ isIntersecting }] ) => { if (isIntersecting) { el.src = binding.value ; } }); } });
测试图片资源是否发出
06. Home-懒加载指令优化
Note
逻辑书写位置不合理
问:懒加载指令的逻辑直接写到入口文件中,合理吗?
答:不合理,入口文件通常只做一些初始化的事情,不应该包含太多的逻辑代码,可以通过插件的方法把懒加载指令封装为插件 ,main.js
入口文件只需要负责注册插件即可 。
创建 src/directives/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { useIntersectionObserver } from "@vueuse/core" ;export const lazyPlugin = { install (app ) { app.directive ('img-lazy' , { mounted (el, binding ) { console .log (el, binding); useIntersectionObserver (el, ([{ isIntersecting }] ) => { if (isIntersecting) { el.src = binding.value ; } }); } }); } }
main.js
中使用这个插件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import '@/styles/common.scss' import { lazyPlugin } from '@/directives' const app = createApp (App ) app.use (createPinia ()) app.use (router) app.use (lazyPlugin) app.mount ('#app' )
07. Home-Product 产品列表实现
Note
Product 产品列表
Product 产品列表是一个常规的列表渲染,实现步骤如下:
熟悉并准备静态模板
封装接口
获取数据渲染模板
图片懒加载
src/apis/home.js
:
1 2 3 4 5 export const getGoodsAPI = ( ) => { return httpInstance ({ url : 'home/goods' }); }
Home/components/HomeProduct.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 <script setup> import HomePanel from './HomePanel.vue' import { getGoodsAPI } from '@/apis/home' import { onMounted, ref } from 'vue' const goodsProduct = ref([]) const getGoodsList = async () => { const res = await getGoodsAPI() goodsProduct.value = res.result } onMounted(()=>{ getGoodsList(); }); </script> <template> <div class="home-product"> <HomePanel :title="cate.name" v-for="cate in goodsProduct" :key="cate.id"> <div class="box"> <RouterLink class="cover" to="/"> <img v-img-lazy="cate.picture" /> <strong class="label"> <span>{{ cate.name }}馆</span> <span>{{ cate.saleInfo }}</span> </strong> </RouterLink> <ul class="goods-list"> <li v-for="good in cate.goods" :key="good.id"> <RouterLink to="/" class="goods-item"> <img v-img-lazy="good.picture" alt="" /> <p class="name ellipsis">{{ good.name }}</p> <p class="desc ellipsis">{{ good.desc }}</p> <p class="price">¥{{ good.price }}</p> </RouterLink> </li> </ul> </div> </HomePanel> </div> </template> <style scoped lang='scss'> .home-product { background: #fff; margin-top: 20px; .sub { margin-bottom: 2px; a { padding: 2px 12px; font-size: 16px; border-radius: 4px; &:hover { background: $xtxColor; color: #fff; } &:last-child { margin-right: 80px; } } } .box { display: flex; .cover { width: 240px; height: 610px; margin-right: 10px; position: relative; img { width: 100%; height: 100%; } .label { width: 188px; height: 66px; display: flex; font-size: 18px; color: #fff; line-height: 66px; font-weight: normal; position: absolute; left: 0; top: 50%; transform: translate3d(0, -50%, 0); span { text-align: center; &:first-child { width: 76px; background: rgba(0, 0, 0, 0.9); } &:last-child { flex: 1; background: rgba(0, 0, 0, 0.7); } } } } .goods-list { width: 990px; display: flex; flex-wrap: wrap; li { width: 240px; height: 300px; margin-right: 10px; margin-bottom: 10px; &:nth-last-child(-n + 4) { margin-bottom: 0; } &:nth-child(4n) { margin-right: 0; } } } .goods-item { display: block; width: 220px; padding: 20px 30px; text-align: center; transition: all .5s; &:hover { transform: translate3d(0, -3px, 0); box-shadow: 0 3px 8px rgb(0 0 0 / 20%); } img { width: 160px; height: 160px; } p { padding-top: 10px; } .name { font-size: 16px; } .desc { color: #999; height: 29px; } .price { color: $priceColor; font-size: 20px; } } } } </style>
08. Home-GoodsItem 组件封装
Note
为什么要封装 Goodsltem 组件
在小兔鲜项目的很多个业务模块中都需要用到同样的商品展示模块,没必要重复定义,封装起来,方便复用
如何封装
核心思想:把要显示的数据对象设计为 props 参数 ,传入什么数据对象就显示什么数据
GoodsItem.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 <script setup> import { onMounted } from 'vue'; defineProps({ goods: { type: Object, default: () => { } } }) onMounted(() => { console.log(goods); }); </script> <template> <RouterLink to="/" class="goods-item"> <img :src="goods.picture" alt="" /> <p class="name ellipsis">{{ goods.name }}</p> <p class="desc ellipsis">{{ goods.desc }}</p> <p class="price">¥{{ goods.price }}</p> </RouterLink> </template> <style scoped lang="scss"> .goods-item { display: block; width: 220px; padding: 20px 30px; text-align: center; transition: all .5s; &:hover { transform: translate3d(0, -3px, 0); box-shadow: 0 3px 8px rgb(0 0 0 / 20%); } img { width: 160px; height: 160px; } p { padding-top: 10px; } .name { font-size: 16px; } .desc { color: #999; height: 29px; } .price { color: $priceColor; font-size: 20px; } } </style>
HomeProduct.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 <script setup> import HomePanel from './HomePanel.vue' import { getGoodsAPI } from '@/apis/home' import { onMounted, ref } from 'vue' import GoodsItem from './GoodsItem.vue' const goodsProduct = ref([]) const getGoods = async () => { const res = await getGoodsAPI() goodsProduct.value = res.result } onMounted(() => { getGoods(); }); </script> <template> <div class="home-product"> <HomePanel :title="cate.name" v-for="cate in goodsProduct" :key="cate.id"> <div class="box"> <RouterLink class="cover" to="/"> <img :src="cate.picture" /> <strong class="label"> <span>{{ cate.name }}馆</span> <span>{{ cate.saleInfo }}</span> </strong> </RouterLink> <ul class="goods-list"> <li v-for="goods in cate.goods" :key="goods.id"> <RouterLink to="/" class="goods-item"> <GoodsItem :goods="goods" /> </RouterLink> </li> </ul> </div> </HomePanel> </div> </template> <style scoped lang='scss'> .home-product { background: #fff; margin-top: 20px; .sub { margin-bottom: 2px; a { padding: 2px 12px; font-size: 16px; border-radius: 4px; &:hover { background: $xtxColor; color: #fff; } &:last-child { margin-right: 80px; } } } .box { display: flex; .cover { width: 240px; height: 610px; margin-right: 10px; position: relative; img { width: 100%; height: 100%; } .label { width: 188px; height: 66px; display: flex; font-size: 18px; color: #fff; line-height: 66px; font-weight: normal; position: absolute; left: 0; top: 50%; transform: translate3d(0, -50%, 0); span { text-align: center; &:first-child { width: 76px; background: rgba(0, 0, 0, 0.9); } &:last-child { flex: 1; background: rgba(0, 0, 0, 0.7); } } } } .goods-list { width: 990px; display: flex; flex-wrap: wrap; li { width: 240px; height: 300px; margin-right: 10px; margin-bottom: 10px; &:nth-last-child(-n + 4) { margin-bottom: 0; } &:nth-child(4n) { margin-right: 0; } } } .goods-item { display: block; width: 220px; padding: 20px 30px; text-align: center; transition: all .5s; &:hover { transform: translate3d(0, -3px, 0); box-shadow: 0 3px 8px rgb(0 0 0 / 20%); } img { width: 160px; height: 160px; } p { padding-top: 10px; } .name { font-size: 16px; } .desc { color: #999; height: 29px; } .price { color: $priceColor; font-size: 20px; } } } } </style>
09. 一级分类-整体认识和路由配置
修改 router/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import { createRouter, createWebHistory } from 'vue-router' import Layout from '@/views/Layout/index.vue' import Login from '@/views/Login/index.vue' import Home from '@/views/Home/index.vue' import Category from '@/views/Category/index.vue' const router = createRouter ({ history : createWebHistory (import .meta .env .BASE_URL ), routes : [ { path : '/' , name : 'home' , component : Layout , children : [ { path : '' , component : Home }, { path : 'category/:id' , component : Category } ] }, { path : '/login' , component : Login }, ], })export default router
修改 LayoutHeader.vue
和 LayoutFixed.vue
:
1 <RouterLink :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
10. 一级分类-面包屑导航渲染
Note
面包屑导航渲染
准备组件模板
views/Category/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 <script setup> </script> <template> <div class="top-category"> <div class="container m-top-20"> <!-- 面包屑 --> <div class="bread-container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item>居家</el-breadcrumb-item> </el-breadcrumb> </div> </div> </div> </template> <style scoped lang="scss"> .top-category { h3 { font-size: 28px; color: #666; font-weight: normal; text-align: center; line-height: 100px; } .sub-list { margin-top: 20px; background-color: #fff; ul { display: flex; padding: 0 32px; flex-wrap: wrap; li { width: 168px; height: 160px; a { text-align: center; display: block; font-size: 16px; img { width: 100px; height: 100px; } p { line-height: 40px; } &:hover { color: $xtxColor; } } } } } .ref-goods { background-color: #fff; margin-top: 20px; position: relative; .head { .xtx-more { position: absolute; top: 20px; right: 20px; } .tag { text-align: center; color: #999; font-size: 20px; position: relative; top: -20px; } } .body { display: flex; justify-content: space-around; padding: 0 40px 30px; } } .bread-container { padding: 25px 0; } } </style>
封装接口函数
src/apis/category.js
:
1 2 3 4 5 6 7 8 9 10 import httpInstance from '@/utils/http' ;export function getCategoryAPI (id ) { return httpInstance ({ url : '/category' , params : { id } }); }
调用接口获取数据(使用路由参数)
渲染模板
views/Category/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 <script setup> import { getCategoryAPI } from '@/apis/category'; import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; const categoryData = ref([]); const route = useRoute(); const getCategory = async () => { const res = await getCategoryAPI(route.params.id); categoryData.value = res.result; console.log(categoryData.value); } onMounted(() => { getCategory(); }) </script> <template> <div class="top-category"> <div class="container m-top-20"> <!-- 面包屑 --> <div class="bread-container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item>{{categoryData.name}}</el-breadcrumb-item> </el-breadcrumb> </div> </div> </div> </template> <style scoped lang="scss"> .top-category { h3 { font-size: 28px; color: #666; font-weight: normal; text-align: center; line-height: 100px; } .sub-list { margin-top: 20px; background-color: #fff; ul { display: flex; padding: 0 32px; flex-wrap: wrap; li { width: 168px; height: 160px; a { text-align: center; display: block; font-size: 16px; img { width: 100px; height: 100px; } p { line-height: 40px; } &:hover { color: $xtxColor; } } } } } .ref-goods { background-color: #fff; margin-top: 20px; position: relative; .head { .xtx-more { position: absolute; top: 20px; right: 20px; } .tag { text-align: center; color: #999; font-size: 20px; position: relative; top: -20px; } } .body { display: flex; justify-content: space-around; padding: 0 40px 30px; } } .bread-container { padding: 25px 0; } } </style>
11. 一级分类-轮播图功能实现
Note
分类轮播图实现
分类轮播图和首页轮播图的区别只有一个,接口参数不同 ,其余逻辑完成一致
请求参数
参数名
位置
类型
必填
说明
distributionSite
query
string
否
示例值:1 说明:广告区域展示位置(投放位置,1 为首页,2 为分类商品页)默认是 1
改造先前的接口(适配参数)
src/apis/home.js
:
1 2 3 4 5 6 7 8 9 export function getBannerAPI (params = {} ) { const { distribution = '1' } = params; return httpInstance ({ url : `home/banner` , params : { distribution } }); }
迁移首页轮播图逻辑
views/Category/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 <script setup> import { getBannerAPI } from '@/apis/home'; import { ref, onMounted } from 'vue'; const bannerList = ref([]); const getBanner = async () => { const res = await getBannerAPI(); bannerList.value = res.result; } onMounted(() => { getBanner(); }) </script> <template> <div class="home-banner"> <el-carousel height="500px"> <el-carousel-item v-for="item in bannerList" :key="item.id"> <img :src="item.imgUrl" alt=""> </el-carousel-item> </el-carousel> </div> </template> <style scoped lang='scss'> .home-banner { width: 1240px; height: 500px; position: absolute; left: 0; top: 0; z-index: 98; img { width: 100%; height: 500px; } } </style>
12. 一级分类-激活状态控制和分类列表渲染
激活状态显示:
RouterLink 组件默认支持激活样式显示的类名,只需要给 active-class
属性设置对应的类名 即可
views/Layout/components/LayoutHeader.vue
和 LayoutFixed.vue
:
1 <RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</RouterLink>
分类列表渲染
分类的数据已经在面包屑导航实现的时候获取到了,只需要通过 v-for
遍历出来即可
准备分类模板:
v-for 遍历已有数据
views/Category/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 <script setup> import { getCategoryAPI } from '@/apis/category'; import { getBannerAPI } from '@/apis/home'; import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; import GoodsItem from '../Home/components/GoodsItem.vue'; const bannerList = ref([]); const getBanner = async () => { const res = await getBannerAPI(); bannerList.value = res.result; } const categoryData = ref([]); const route = useRoute(); const getCategory = async () => { const res = await getCategoryAPI(route.params.id); categoryData.value = res.result; console.log(categoryData.value); } onMounted(() => { getCategory(); getBanner(); }) </script> <template> <div class="top-category"> <div class="container m-top-20"> <!-- 面包屑 --> <div class="bread-container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item>{{ categoryData.name }}</el-breadcrumb-item> </el-breadcrumb> </div> <!-- 轮播图 --> <div class="home-banner"> <el-carousel height="500px"> <el-carousel-item v-for="item in bannerList" :key="item.id"> <img :src="item.imgUrl" alt=""> </el-carousel-item> </el-carousel> </div> <!-- 分类商品 --> <div class="sub-list"> <h3>全部分类</h3> <ul> <li v-for="i in categoryData.children" :key="i.id"> <RouterLink to="/"> <img :src="i.picture" /> <p>{{ i.name }}</p> </RouterLink> </li> </ul> </div> <div class="ref-goods" v-for="item in categoryData.children" :key="item.id"> <div class="head"> <h3>- {{ item.name }}-</h3> </div> <div class="body"> <GoodsItem v-for="good in item.goods" :goods="good" :key="good.id" /> </div> </div> </div> </div> </template> <style scoped lang="scss"> .top-category { h3 { font-size: 28px; color: #666; font-weight: normal; text-align: center; line-height: 100px; } .sub-list { margin-top: 20px; background-color: #fff; ul { display: flex; padding: 0 32px; flex-wrap: wrap; li { width: 168px; height: 160px; a { text-align: center; display: block; font-size: 16px; img { width: 100px; height: 100px; } p { line-height: 40px; } &:hover { color: $xtxColor; } } } } } .ref-goods { background-color: #fff; margin-top: 20px; position: relative; .head { .xtx-more { position: absolute; top: 20px; right: 20px; } .tag { text-align: center; color: #999; font-size: 20px; position: relative; top: -20px; } } .body { display: flex; justify-content: space-around; padding: 0 40px 30px; } } .bread-container { padding: 25px 0; } } .home-banner { width: 1240px; height: 500px; margin: 0 auto; img { width: 100%; height: 500px; } } </style>
13. 一级分类-解决路由缓存问题
Note
路由缓存问题产生的原因是什么?
路由只有参数变化时,会复用组件实例
使用带有参数的路由时需要注意的是,当用户从 /users/johnny
导航到 /users/jolyne
时,相同的组件实例将被重复使用。因为两个路由都渲染同个组件,比起销毁再创建,复用则显得更加高效。不过,这也意味着组件的生命周期钩子不会被调用 。
俩种方案都可以解决路由缓存问题,如何选择呢?
14. 一级分类-使用逻辑函数拆分业务
Note
概念理解
基于逻辑函数拆分业务是指把同一个组件中独立的业务代码通过函数做封装处理 ,提升代码的可维护性
具体怎么做
实现步骤:
按照业务声明以 use
打头 的逻辑函数
把独立的业务逻辑 封装到各个函数内部
函数内部把组件中需要用到的数据或者方法 return 出去
在组件中调用函数 把数据或者方法组合回来使用
创建 views/Category/composables/useBanner.js
和 views/Category/composables/useCategory.js
:
views/Category/index.vue views/Category/composables/useBanner.js views/Category/composables/useCategory.js
1 2 3 4 5 6 7 8 <script setup> import GoodsItem from '../Home/components/GoodsItem.vue'; import { useBanner} from './composables/useBanner'; import { useCategory } from './composables/useCategory'; const { bannerList } = useBanner(); const { categoryData } = useCategory(); </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { getBannerAPI } from '@/apis/home' ;import { onMounted, ref } from 'vue' ;export function useBanner ( ) { const bannerList = ref ([]); const getBanner = async ( ) => { const res = await getBannerAPI (); bannerList.value = res.result ; } onMounted (() => { getBanner (); }); return { bannerList } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import { getCategoryAPI } from '@/apis/category' ;import { onMounted, ref } from 'vue' ;import { onBeforeRouteUpdate, useRoute } from 'vue-router' ;export function useCategory ( ) { const route = useRoute (); const categoryData = ref ([]); const getCategory = async (id = route.params.id ) => { const res = await getCategoryAPI (id); categoryData.value = res.result ; } onMounted (() => { getCategory (); }); onBeforeRouteUpdate ((to ) => { getCategory (to.params .id ); }); return { categoryData } }
day04
01. 二级分类-整体认识和路由配置
创建路由组件
创建 src/views/SubCategory/index.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 <script setup> </script> <template> <div class="container "> <!-- 面包屑 --> <div class="bread-container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: '/' }">居家 </el-breadcrumb-item> <el-breadcrumb-item>居家生活用品</el-breadcrumb-item> </el-breadcrumb> </div> <div class="sub-container"> <el-tabs> <el-tab-pane label="最新商品" name="publishTime"></el-tab-pane> <el-tab-pane label="最高人气" name="orderNum"></el-tab-pane> <el-tab-pane label="评论最多" name="evaluateNum"></el-tab-pane> </el-tabs> <div class="body"> <!-- 商品列表--> </div> </div> </div> </template> <style lang="scss" scoped> .bread-container { padding: 25px 0; color: #666; } .sub-container { padding: 20px 10px; background-color: #fff; .body { display: flex; flex-wrap: wrap; padding: 0 10px; } .goods-item { display: block; width: 220px; margin-right: 20px; padding: 20px 30px; text-align: center; img { width: 160px; height: 160px; } p { padding-top: 10px; } .name { font-size: 16px; } .desc { color: #999; height: 29px; } .price { color: $priceColor; font-size: 20px; } } .pagination-container { margin-top: 20px; display: flex; justify-content: center; } } </style>
配置路由关系
修改 src/router/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 import { createRouter, createWebHistory } from 'vue-router' import Layout from '@/views/Layout/index.vue' import Login from '@/views/Login/index.vue' import Home from '@/views/Home/index.vue' import Category from '@/views/Category/index.vue' import SubCategory from '@/views/SubCategory/index.vue' const router = createRouter ({ history : createWebHistory (import .meta .env .BASE_URL ), routes : [ { path : '/' , name : 'home' , component : Layout , children : [ { path : '' , component : Home }, { path : 'category/:id' , component : Category }, { path :'subcategory/sub/:id' , component : SubCategory } ] }, { path : '/login' , component : Login }, ], })export default router
修改模板实现跳转
修改 src/views/Category/index.vue
(分类商品下):
1 <RouterLink :to="`/category/sub/${i.id}`">
02. 二级分类-面包屑导航实现
封装接口
给 src/apis/category.js
里加一个 getCategoryFilterAPI
:
1 2 3 4 5 6 7 8 export const getCategoryFilterAPI = (id ) => { return httpInstance ({ url : '/category/sub/filter' , params : { id } }); }
调用接口渲染模板
修改 SubCategory/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <script setup> import { getCategoryFilterAPI } from '@/apis/category'; import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; const categoryData = ref({}); const router = useRoute(); const getCategoryData = async () => { const res = await getCategoryFilterAPI(router.params.id); categoryData.value = res.result; console.log(categoryData.value); } onMounted(() => getCategoryData()); </script>
测试跳转
修改 SubCategory/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <template> <div class="container "> <!-- 面包屑 --> <div class="bread-container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: `/category/${categoryData.parentId}` }">{{ categoryData.parentName }}</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: `/category/${categoryData.id}` }">{{ categoryData.name }}</el-breadcrumb-item> </el-breadcrumb> </div> <div class="sub-container"> <el-tabs> <el-tab-pane label="最新商品" name="publishTime"></el-tab-pane> <el-tab-pane label="最高人气" name="orderNum"></el-tab-pane> <el-tab-pane label="评论最多" name="evaluateNum"></el-tab-pane> </el-tabs> <div class="body"> <!-- 商品列表--> </div> </div> </div> </template>
03. 二级分类-基础商品列表实现
实现基础列表渲染(基础参数)
给 src/apis/category.js
里加一个 getSubCategoryAPI
:
1 2 3 4 5 6 7 export const getSubCategoryAPI = (data ) => { return httpInstance ({ url : '/category/goods/temporary' , method : 'POST' , data }); }
添加额外参数实现筛选功能
修改 SubCategory/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { getCategoryFilterAPI, getSubCategoryAPI } from '@/apis/category' ; ...const goodsList = ref ([]);const reqData = ref ({ categoryId : router.params .id , page : 1 , pageSize : 20 , sortField : 'publishTime' , });const getGoodsList = async ( ) => { const res = await getSubCategoryAPI (reqData.value ); goodsList.value = res.result .items ; }onMounted (() => getGoodsList ());
商品列表中添加:
1 <GoodsItem v-for="goods in goodsList" :goods="goods" :key="goods.id"></GoodsItem>
04. 二级分类-列表筛选功能实现
Note
核心逻辑:点击 tab,切换筛选条件参数 sortField ,重新发送列表请求
获取激活项数据
根据 组件 | Element 中的用法,设置 SubCategory/index.vue
中的 el-tabs
组件:
1 2 3 4 5 <el-tabs v-model="reqData.sortField" @tab-change="tabChange"> <el-tab-pane label="最新商品" name="publishTime"></el-tab-pane> <el-tab-pane label="最高人气" name="orderNum"></el-tab-pane> <el-tab-pane label="评论最多" name="evaluateNum"></el-tab-pane> </el-tabs>
scripts
中添加切换 tab 的回调函数的实现:
1 2 3 4 5 6 const tabChange = () => { console.log('tab 切换了' , reqData.value.sortField); reqData.value.page = 1 ; getGoodsList(); }
05. 二级分类-列表无限加载实现
无限加载功能实现
核心实现逻辑:使用 elementPlus 提供的 v-infinite-scroll 指令监听是否满足触底条件 ,满足加载条件时让页数参数加一获取下一页数据,做新老数据拼接渲染 。
配置 v-infinite-scroll
1 2 3 4 <div class="body" v-infinite-scroll="load"> <!-- 商品列表--> <GoodsItem v-for="goods in goodsList" :goods="goods" :key="goods.id"></GoodsItem> </div>
页数加一获取下一页数据
老数据和新数据拼接
加载完毕结束监听
1 2 3 4 5 6 7 8 9 10 11 const disabled = ref (false );const load = async ( ) => { console .log ('加载更多' ); reqData.value .page ++; const res = await getSubCategoryAPI (reqData.value ); goodsList.value = [...goodsList.value , ...res.result .items ]; if (res.result .items .length === 0 ) { disabled.value = true ; } }
06. 二级分类-定制路由滚动行为
定制路由行为解决什么问题
在不同路由切换的时候,可以自动滚动到页面的顶部 ,而不是停留在原先的位置
如何配置:vue-router
支持 scrollBehavior
配置项,可以指定路由切换时的滚动位置
编辑 src/router/index.js
:
1 2 3 4 5 6 const router = createRouter ({ ... scrollBehavior ( ) { return { top : 0 } } })
07. 详情页-整体认识和路由配置
创建详情组件
创建 src/views/Detail/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 <script setup> </script> <template> <div class="xtx-goods-page"> <div class="container"> <div class="bread-container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: '/' }">母婴 </el-breadcrumb-item> <el-breadcrumb-item :to="{ path: '/' }">跑步鞋 </el-breadcrumb-item> <el-breadcrumb-item>抓绒保暖,毛毛虫子儿童运动鞋</el-breadcrumb-item> </el-breadcrumb> </div> <!-- 商品信息 --> <div class="info-container"> <div> <div class="goods-info"> <div class="media"> <!-- 图片预览区 --> <!-- 统计数量 --> <ul class="goods-sales"> <li> <p>销量人气</p> <p> 100+ </p> <p><i class="iconfont icon-task-filling"></i>销量人气</p> </li> <li> <p>商品评价</p> <p>200+</p> <p><i class="iconfont icon-comment-filling"></i>查看评价</p> </li> <li> <p>收藏人气</p> <p>300+</p> <p><i class="iconfont icon-favorite-filling"></i>收藏商品</p> </li> <li> <p>品牌信息</p> <p>400+</p> <p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p> </li> </ul> </div> <div class="spec"> <!-- 商品信息区 --> <p class="g-name"> 抓绒保暖,毛毛虫儿童鞋 </p> <p class="g-desc">好穿 </p> <p class="g-price"> <span>200</span> <span> 100</span> </p> <div class="g-service"> <dl> <dt>促销</dt> <dd>12月好物放送,App领券购买直降120元</dd> </dl> <dl> <dt>服务</dt> <dd> <span>无忧退货</span> <span>快速退款</span> <span>免费包邮</span> <a href="javascript:;">了解详情</a> </dd> </dl> </div> <!-- sku组件 --> <!-- 数据组件 --> <!-- 按钮组件 --> <div> <el-button size="large" class="btn"> 加入购物车 </el-button> </div> </div> </div> <div class="goods-footer"> <div class="goods-article"> <!-- 商品详情 --> <div class="goods-tabs"> <nav> <a>商品详情</a> </nav> <div class="goods-detail"> <!-- 属性 --> <ul class="attrs"> <li v-for="item in 3" :key="item.value"> <span class="dt">白色</span> <span class="dd">纯棉</span> </li> </ul> <!-- 图片 --> </div> </div> </div> <!-- 24热榜+专题推荐 --> <div class="goods-aside"> </div> </div> </div> </div> </div> </div> </template> <style scoped lang='scss'> .xtx-goods-page { .goods-info { min-height: 600px; background: #fff; display: flex; .media { width: 580px; height: 600px; padding: 30px 50px; } .spec { flex: 1; padding: 30px 30px 30px 0; } } .goods-footer { display: flex; margin-top: 20px; .goods-article { width: 940px; margin-right: 20px; } .goods-aside { width: 280px; min-height: 1000px; } } .goods-tabs { min-height: 600px; background: #fff; } .goods-warn { min-height: 600px; background: #fff; margin-top: 20px; } .number-box { display: flex; align-items: center; .label { width: 60px; color: #999; padding-left: 10px; } } .g-name { font-size: 22px; } .g-desc { color: #999; margin-top: 10px; } .g-price { margin-top: 10px; span { &::before { content: "¥"; font-size: 14px; } &:first-child { color: $priceColor; margin-right: 10px; font-size: 22px; } &:last-child { color: #999; text-decoration: line-through; font-size: 16px; } } } .g-service { background: #f5f5f5; width: 500px; padding: 20px 10px 0 10px; margin-top: 10px; dl { padding-bottom: 20px; display: flex; align-items: center; dt { width: 50px; color: #999; } dd { color: #666; &:last-child { span { margin-right: 10px; &::before { content: "•"; color: $xtxColor; margin-right: 2px; } } a { color: $xtxColor; } } } } } .goods-sales { display: flex; width: 400px; align-items: center; text-align: center; height: 140px; li { flex: 1; position: relative; ~li::after { position: absolute; top: 10px; left: 0; height: 60px; border-left: 1px solid #e4e4e4; content: ""; } p { &:first-child { color: #999; } &:nth-child(2) { color: $priceColor; margin-top: 10px; } &:last-child { color: #666; margin-top: 10px; i { color: $xtxColor; font-size: 14px; margin-right: 2px; } &:hover { color: $xtxColor; cursor: pointer; } } } } } } .goods-tabs { min-height: 600px; background: #fff; nav { height: 70px; line-height: 70px; display: flex; border-bottom: 1px solid #f5f5f5; a { padding: 0 40px; font-size: 18px; position: relative; >span { color: $priceColor; font-size: 16px; margin-left: 10px; } } } } .goods-detail { padding: 40px; .attrs { display: flex; flex-wrap: wrap; margin-bottom: 30px; li { display: flex; margin-bottom: 10px; width: 50%; .dt { width: 100px; color: #999; } .dd { flex: 1; color: #666; } } } >img { width: 100%; } } .btn { margin-top: 20px; } .bread-container { padding: 25px 0; } </style>
绑定路由关系(参数)
编辑 src/router/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import { createRouter, createWebHistory } from 'vue-router' import Layout from '@/views/Layout/index.vue' import Login from '@/views/Login/index.vue' import Home from '@/views/Home/index.vue' import Category from '@/views/Category/index.vue' import SubCategory from '@/views/SubCategory/index.vue' import Detail from '@/views/Detail/index.vue' const router = createRouter ({ history : createWebHistory (import .meta .env .BASE_URL ), routes : [ { path : '/' , name : 'home' , component : Layout , children : [ { path : '' , component : Home }, { path : 'category/:id' , component : Category }, { path :'category/sub/:id' , component : SubCategory }, { path : 'detail/:id' , component : Detail } ] }, { path : '/login' , component : Login }, ], scrollBehavior ( ) { return { top : 0 } } })export default router
绑定模板测试跳转
修改 src/views/Home/components/HomeNew.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 <template> <HomePanel title="新品推荐" sub-title="新鲜出炉 品质靠谱"> <ul class="goods-list"> <li v-for="item in newList" :key="item.id"> <RouterLink :to="`/detail/${item.id}`"> <img :src="item.picture" alt="" /> <p class="name">{{ item.name }}</p> <p class="price">¥{{ item.price }}</p> </RouterLink> </li> </ul> </HomePanel> </template>
08. 详情页-基础数据渲染
封装接口
编辑 src/apis/details.js
:
1 2 3 4 5 6 7 8 9 10 import httpInstance from '@/utils/http' ;export const getDetail = (id ) => { return httpInstance ({ url : `/goods` , params : { id } }) }
调用获取数据
渲染模板
修改 src/Detail/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 <script setup> import { getDetail } from '@/apis/detail'; import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; const goods = ref({}); const route = useRoute(); const getGoods = async () => { const res = await getDetail(route.params.id); goods.value = res.result; }; onMounted(()=>getGoods()); </script> <template> <div class="xtx-goods-page"> <div class="container" v-if="goods.details"> <div class="bread-container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: `/category/${goods.categories[1].id}` }">{{ goods.categories[1].name }} </el-breadcrumb-item> <el-breadcrumb-item :to="{ path: `/category/sub/${goods.categories[0].id}` }">{{ goods.categories[0].name }} </el-breadcrumb-item> <el-breadcrumb-item>抓绒保暖,毛毛虫子儿童运动鞋</el-breadcrumb-item> </el-breadcrumb> </div> <!-- 商品信息 --> <div class="info-container"> <div> <div class="goods-info"> <div class="media"> <!-- 图片预览区 --> <!-- 统计数量 --> <ul class="goods-sales"> <li> <p>销量人气</p> <p> {{ goods.salesCount }}+ </p> <p><i class="iconfont icon-task-filling"></i>销量人气</p> </li> <li> <p>商品评价</p> <p> {{ goods.commentCount }}+</p> <p><i class="iconfont icon-comment-filling"></i>查看评价</p> </li> <li> <p>收藏人气</p> <p> {{ goods.collectCount }}+</p> <p><i class="iconfont icon-favorite-filling"></i>收藏商品</p> </li> <li> <p>品牌信息</p> <p> {{ goods.brand.name }}</p> <p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p> </li> </ul> </div> <div class="spec"> <!-- 商品信息区 --> <p class="g-name"> 抓绒保暖,毛毛虫儿童鞋 </p> <p class="g-desc">好穿 </p> <p class="g-price"> <span>200</span> <span> 100</span> </p> <div class="g-service"> <dl> <dt>促销</dt> <dd>12月好物放送,App领券购买直降120元</dd> </dl> <dl> <dt>服务</dt> <dd> <span>无忧退货</span> <span>快速退款</span> <span>免费包邮</span> <a href="javascript:;">了解详情</a> </dd> </dl> </div> <!-- sku组件 --> <!-- 数据组件 --> <!-- 按钮组件 --> <div> <el-button size="large" class="btn"> 加入购物车 </el-button> </div> </div> </div> <div class="goods-footer"> <div class="goods-article"> <!-- 商品详情 --> <div class="goods-tabs"> <nav> <a>商品详情</a> </nav> <div class="goods-detail"> <!-- 属性 --> <ul class="attrs"> <li v-for="item in 3" :key="item.value"> <span class="dt">白色</span> <span class="dd">纯棉</span> </li> </ul> <!-- 图片 --> </div> </div> </div> <!-- 24热榜+专题推荐 --> <div class="goods-aside"> </div> </div> </div> </div> </div> </div> </template>
Note
渲染模版时遇到对象的多层属性(如 good.details.pictures
)访问可能出现什么问题?
TypeError: Cannot read properties of undefined (reading ‘properties’)
09. 详情页-热榜区-基础组件封装和数据渲染
Note
结论:两块热榜相比,结构一致,标题 title 和列表内容不同
封装 Hot 热榜组件
在 src/apis/details.js
中封装接口:
1 2 3 4 5 6 7 8 9 10 export const getHotGoodsAPI = ({id, type, limit=3 } ) => { return httpInstance ({ url : '/goods/hot' , params : { id, type, limit } }); }
创建 src/views/Details/components/DetailHot.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 <script setup> import { getHotGoodsAPI } from '@/apis/detail'; import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; // 1. 封装接口 // 2. 调用接口渲染模板 const hotList = ref([]); const route = useRoute(); const getHotList = async () => { const res = await getHotGoodsAPI({ id: route.params.id, type: 1, }); hotList.value = res.result; }; onMounted(()=> getHotList()); </script> <template> <div class="goods-hot"> <h3>周日榜单</h3> <!-- 商品区块 --> <RouterLink to="/" class="goods-item" v-for="item in hotList" :key="item.id"> <img :src="item.picture" alt="" /> <p class="name ellipsis">{{ item.name }}</p> <p class="desc ellipsis">{{ item.desc }}</p> <p class="price">¥{{ item.price}}</p> </RouterLink> </div> </template> <style scoped lang="scss"> .goods-hot { h3 { height: 70px; background: $helpColor; color: #fff; font-size: 18px; line-height: 70px; padding-left: 25px; margin-bottom: 10px; font-weight: normal; } .goods-item { display: block; padding: 20px 30px; text-align: center; background: #fff; img { width: 160px; height: 160px; } p { padding-top: 10px; } .name { font-size: 16px; } .desc { color: #999; height: 29px; } .price { color: $priceColor; font-size: 20px; } } } </style>
10. 详情页-热榜区-适配不同 title 和数据列表
修改 views/Detail/components/DetailHot.vue
,使用 defineProps
接收参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 <script setup> import { getHotGoodsAPI } from '@/apis/detail'; import { onMounted, ref, computed, defineProps } from 'vue'; import { useRoute } from 'vue-router'; // 设计 props 参数 适配不同的 title 和数据 const props = defineProps({ hotType: { type: Number, } }); // 适配 title 1- 24小时热榜 2- 周热榜 const TYPEMAP = { 1: '24小时热榜', 2: '周热榜', } const title = computed(() => TYPEMAP[props.hotType]) // 1. 封装接口 // 2. 调用接口渲染模板 const hotList = ref([]); const route = useRoute(); const getHotList = async () => { const res = await getHotGoodsAPI({ id: route.params.id, type: props.hotType, }); hotList.value = res.result; }; onMounted(() => getHotList()); </script> <template> <div class="goods-hot"> <h3>{{ title }}</h3> <!-- 商品区块 --> <RouterLink to="/" class="goods-item" v-for="item in hotList" :key="item.id"> <img :src="item.picture" alt="" /> <p class="name ellipsis">{{ item.name }}</p> <p class="desc ellipsis">{{ item.desc }}</p> <p class="price">¥{{ item.price }}</p> </RouterLink> </div> </template>
views/Detail/index.vue
中传递参数:
1 2 <DetailHot :hot-type="1"/> <DetailHot :hot-type="2"/>
11. 详情页-图片预览组件-小图切换大图显示
Note
思路:维护一个数组图片列表,鼠标划入小图记录当前小图下标值,通过下标值在数组中取对应图片 ,显示到大图位置。
创建 src/components/ImageView/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 <script setup> import { ref } from "vue" // 图片列表 const imageList = [ "https://yanxuan-item.nosdn.127.net/d917c92e663c5ed0bb577c7ded73e4ec.png", "https://yanxuan-item.nosdn.127.net/e801b9572f0b0c02a52952b01adab967.jpg", "https://yanxuan-item.nosdn.127.net/b52c447ad472d51adbdde1a83f550ac2.jpg", "https://yanxuan-item.nosdn.127.net/f93243224dc37674dfca5874fe089c60.jpg", "https://yanxuan-item.nosdn.127.net/f881cfe7de9a576aaeea6ee0d1d24823.jpg" ] const activeIndex = ref(0) // 当前显示的图片索引 const enterhandler = (i) => { activeIndex.value = i } </script> <template> <div class="goods-image"> <!-- 左侧大图--> <div class="middle" ref="target"> <img :src="imageList[activeIndex]" alt="" /> <!-- 蒙层小滑块 --> <div class="layer" :style="{ left: `0px`, top: `0px` }"></div> </div> <!-- 小图列表 --> <ul class="small"> <li v-for="(img, i) in imageList" :key="i" @mouseenter="enterhandler(i)" :class="{ active: i === activeIndex }"> <img :src="img" alt="" /> </li> </ul> <!-- 放大镜大图 --> <div class="large" :style="[ { backgroundImage: `url(${imageList[0]})`, backgroundPositionX: `0px`, backgroundPositionY: `0px`, }, ]" v-show="false"></div> </div> </template> <style scoped lang="scss"> .goods-image { width: 480px; height: 400px; position: relative; display: flex; .middle { width: 400px; height: 400px; background: #f5f5f5; } .large { position: absolute; top: 0; left: 412px; width: 400px; height: 400px; z-index: 500; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); background-repeat: no-repeat; // 背景图:盒子的大小 = 2:1 将来控制背景图的移动来实现放大的效果查看 background-position background-size: 800px 800px; background-color: #f8f8f8; } .layer { width: 200px; height: 200px; background: rgba(0, 0, 0, 0.2); // 绝对定位 然后跟随咱们鼠标控制left和top属性就可以让滑块移动起来 left: 0; top: 0; position: absolute; } .small { width: 80px; li { width: 68px; height: 68px; margin-left: 12px; margin-bottom: 15px; cursor: pointer; &:hover, &.active { border: 2px solid $xtxColor; } } } } </style>
在 src/views/Detail/index.vue
中放置这个组件:
1 import ImageView from '@/components/ImageView/index.vue'
1 2 <!-- 图片预览区 --> <ImageView/>
12. 详情页-图片预览组件-放大镜-滑块跟随移动
Note
思路:
获取到当前的鼠标在盒子内的相对位置(useMouseInElement) ,控制滑块跟随鼠标移动(left/top)
有效移动范围内的计算逻辑
横向:100 < elementX < 300,left = elementX - 小滑块宽度一半
纵向:100 < elementY < 300,top = elementy - 小滑块高度一半
边界距离控制
横向:elementX > 300 left = 200 elementX < 100 left = 0
纵向:elementY > 300 top = 200 elementY < 100 top = 0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 <script> ... // 获取鼠标相对位置 const target = ref(null); const { elementX, elementY, idOutside } = useMouseInElement(target); // 控制滑块跟随鼠标移动(监听 elementX/Y 变化,一旦变化 重新设置 left/top) const left = ref(0); const top = ref(0); watch([elementX, elementY, isOutside], () => { if (isOutside.value) return; if (elementX.value > 100 && elementX.value < 300) { left.value = elementX.value - 100; } if (elementY.value > 100 && elementY.value < 300) { top.value = elementY.value - 100; } if (elementX.value > 300) { left.value = 200; } if (elementX.value < 100) { left.value = 0; } if (elementY.value > 300) { top.value = 200; } if (elementY.value < 100) { top.value = 0; } }); </script> <template> <div class="goods-image"> <!-- 左侧大图--> <div class="middle" ref="target"> <img :src="imageList[activeIndex]" alt="" /> <!-- 蒙层小滑块 --> <div class="layer" :style="{ left: `${left}px`, top: `${top}px` }"></div> </div> <!-- 小图列表 --> <ul class="small"> <li v-for="(img, i) in imageList" :key="i" @mouseenter="enterhandler(i)" :class="{ active: i === activeIndex }"> <img :src="img" alt="" /> </li> </ul> <!-- 放大镜大图 --> <div class="large" :style="[ { backgroundImage: `url(${imageList[activeIndex]})`, backgroundPositionX: `0px`, backgroundPositionY: `0px`, }, ]" v-show="false"></div> </div> </template>
13. 详情页-图片预览组件-放大镜-大图效果实现
Note
放大镜效果实现-大图效果实现
效果:为实现放大效果,大图的宽高是小图的俩倍
思路:大图的移动方向和滑块移动方向相反,且数值为 2 倍
放大镜效果实现 - 鼠标移入控制显隐
思路:鼠标移入盒子(isOutside),滑块和大图才显示(v-show)
编辑 src/components/ImageView/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 <script setup> import { useMouseInElement } from "@vueuse/core" import { ref, watch } from "vue" // 图片列表 const imageList = [ "https://yanxuan-item.nosdn.127.net/d917c92e663c5ed0bb577c7ded73e4ec.png", "https://yanxuan-item.nosdn.127.net/e801b9572f0b0c02a52952b01adab967.jpg", "https://yanxuan-item.nosdn.127.net/b52c447ad472d51adbdde1a83f550ac2.jpg", "https://yanxuan-item.nosdn.127.net/f93243224dc37674dfca5874fe089c60.jpg", "https://yanxuan-item.nosdn.127.net/f881cfe7de9a576aaeea6ee0d1d24823.jpg" ] const activeIndex = ref(0) // 当前显示的图片索引 const enterhandler = (i) => { activeIndex.value = i } // 获取鼠标相对位置 const target = ref(null); const { elementX, elementY, isOutside } = useMouseInElement(target); // 控制滑块跟随鼠标移动(监听 elementX/Y 变化,一旦变化 重新设置 left/top) const left = ref(0); const top = ref(0); const positionX = ref(0); const positionY = ref(0); watch([elementX, elementY, isOutside], () => { if (isOutside.value) return; if (elementX.value > 100 && elementX.value < 300) { left.value = elementX.value - 100; } if (elementY.value > 100 && elementY.value < 300) { top.value = elementY.value - 100; } if (elementX.value > 300) { left.value = 200; } if (elementX.value < 100) { left.value = 0; } if (elementY.value > 300) { top.value = 200; } if (elementY.value < 100) { top.value = 0; } positionX.value = -left.value * 2; positionY.value = -top.value * 2; }); </script> <template> <div class="goods-image"> <!-- 左侧大图--> <div class="middle" ref="target"> <img :src="imageList[activeIndex]" alt="" /> <!-- 蒙层小滑块 --> <div class="layer" v-show="!isOutside" :style="{ left: `${left}px`, top: `${top}px` }"></div> </div> <!-- 小图列表 --> <ul class="small"> <li v-for="(img, i) in imageList" :key="i" @mouseenter="enterhandler(i)" :class="{ active: i === activeIndex }"> <img :src="img" alt="" /> </li> </ul> <!-- 放大镜大图 --> <div class="large" :style="[ { backgroundImage: `url(${imageList[0]})`, backgroundPositionX: `${positionX}px`, backgroundPositionY: `${positionY}px`, }, ]" v-show="!isOutside"></div> </div> </template>
14. 详情页-图片预览组件-props 适配和整体总结
编辑 src/components/ImageView/index.vue
,让 imageList
以 props
参数的形式存在:
1 2 3 4 5 6 defineProps ({ imageList : { type : Array , default : () => [] } });
在 src/views/Detail/index.vue
中传参:
1 <ImageView :image-list="goods.mainPictures"/>
15.详情页-SKU 组件熟悉使用
Note
SKU 的概念
存货单位(英语:stock keeping unit,SKU/,cs,kelju:/),也翻译为库存单元 ,是一个会计学名词,定义为库存管理中的最小可用单元 ,例如纺织品中一个 SKU 通常表示规格、颜色、款式,而在连锁零售门店中有时称单品为一个 SKU。
SKU 组件的作用:产出当前用户选择的商品规格 ,为加入购物车操作提供数据信息。
SKU 组件使用
问:在实际工作中,经常会遇到别人写好的组件,熟悉一个三方组件,首先重点看什么?
答:props
和 emit
,props 决定了当前组件接收什么数据,emit 决定了会产出什么数据
验证组件是否成功使用:
传入必要数据是否交互功能正常 → 点击选择规格,是否正常产出数据
导入 src/components/XtxSku
,在 src/views/Detail/index.vue
中使用这个插件:
1 2 <!-- sku组件 --> <XtxSku :goods="goods"/>
16. 详情页-通用组件统一注册全局
Note
为什么要优化
背景:components 目录下有可能还会有很多其他通用型组件,有可能在多个业务模块中共享,所有统一进行全局组件注册比较好
components 插件(把 components 目录下的所有组件进行全局注册)→ main.js(注册插件)
创建 components/index.js
:
1 2 3 4 5 6 7 8 9 10 import ImageView from './ImageView/index.vue' ;import XtxSku from './XtxSku/index.vue' ;export const componentPlugin = { install (app ) { app.component ('ImageView' , ImageView ); app.component ('XtxSku' , XtxSku ); } }
之后便不再需要导入组件。
src/main.js
中引入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import '@/styles/common.scss' import { lazyPlugin } from '@/directives' import { componentPlugin } from '@/components' const app = createApp (App ) app.use (createPinia ()) app.use (router) app.use (lazyPlugin) app.use (componentPlugin) app.mount ('#app' )
day05
01. 登录-整体认识和路由配置
编辑 src/views/Login/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 <script setup> </script> <template> <div> <header class="login-header"> <div class="container m-top-20"> <h1 class="logo"> <RouterLink to="/">小兔鲜</RouterLink> </h1> <RouterLink class="entry" to="/"> 进入网站首页 <i class="iconfont icon-angle-right"></i> <i class="iconfont icon-angle-right"></i> </RouterLink> </div> </header> <section class="login-section"> <div class="wrapper"> <nav> <a href="javascript:;">账户登录</a> </nav> <div class="account-box"> <div class="form"> <el-form label-position="right" label-width="60px" status-icon> <el-form-item label="账户"> <el-input /> </el-form-item> <el-form-item label="密码"> <el-input /> </el-form-item> <el-form-item label-width="22px"> <el-checkbox size="large"> 我已同意隐私条款和服务条款 </el-checkbox> </el-form-item> <el-button size="large" class="subBtn">点击登录</el-button> </el-form> </div> </div> </div> </section> <footer class="login-footer"> <div class="container"> <p> <a href="javascript:;">关于我们</a> <a href="javascript:;">帮助中心</a> <a href="javascript:;">售后服务</a> <a href="javascript:;">配送与验收</a> <a href="javascript:;">商务合作</a> <a href="javascript:;">搜索推荐</a> <a href="javascript:;">友情链接</a> </p> <p>CopyRight © 小兔鲜儿</p> </div> </footer> </div> </template> <style scoped lang='scss'> .login-header { background: #fff; border-bottom: 1px solid #e4e4e4; .container { display: flex; align-items: flex-end; justify-content: space-between; } .logo { width: 200px; a { display: block; height: 132px; width: 100%; text-indent: -9999px; background: url("@/assets/images/logo.png") no-repeat center 18px / contain; } } .sub { flex: 1; font-size: 24px; font-weight: normal; margin-bottom: 38px; margin-left: 20px; color: #666; } .entry { width: 120px; margin-bottom: 38px; font-size: 16px; i { font-size: 14px; color: $xtxColor; letter-spacing: -5px; } } } .login-section { background: url('@/assets/images/login-bg.png') no-repeat center / cover; height: 488px; position: relative; .wrapper { width: 380px; background: #fff; position: absolute; left: 50%; top: 54px; transform: translate3d(100px, 0, 0); box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); nav { font-size: 14px; height: 55px; margin-bottom: 20px; border-bottom: 1px solid #f5f5f5; display: flex; padding: 0 40px; text-align: right; align-items: center; a { flex: 1; line-height: 1; display: inline-block; font-size: 18px; position: relative; text-align: center; } } } } .login-footer { padding: 30px 0 50px; background: #fff; p { text-align: center; color: #999; padding-top: 20px; a { line-height: 1; padding: 0 10px; color: #999; display: inline-block; ~a { border-left: 1px solid #ccc; } } } } .account-box { .toggle { padding: 15px 40px; text-align: right; a { color: $xtxColor; i { font-size: 14px; } } } .form { padding: 0 20px 20px 20px; &-item { margin-bottom: 28px; .input { position: relative; height: 36px; >i { width: 34px; height: 34px; background: #cfcdcd; color: #fff; position: absolute; left: 1px; top: 1px; text-align: center; line-height: 34px; font-size: 18px; } input { padding-left: 44px; border: 1px solid #cfcdcd; height: 36px; line-height: 36px; width: 100%; &.error { border-color: $priceColor; } &.active, &:focus { border-color: $xtxColor; } } .code { position: absolute; right: 1px; top: 1px; text-align: center; line-height: 34px; font-size: 14px; background: #f5f5f5; color: #666; width: 90px; height: 34px; cursor: pointer; } } >.error { position: absolute; font-size: 12px; line-height: 28px; color: $priceColor; i { font-size: 14px; margin-right: 2px; } } } .agree { a { color: #069; } } .btn { display: block; width: 100%; height: 40px; color: #fff; text-align: center; line-height: 40px; background: $xtxColor; &.disabled { background: #cfcdcd; } } } .action { padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; .url { a { color: #999; margin-left: 10px; } } } } .subBtn { background: $xtxColor; width: 100%; color: #fff; } </style>
修改 src/views/Layout/components/LayoutNav.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <script setup> </script> <template> <nav class="app-topnav"> <div class="container"> <ul> <template v-if="false"> <li><a href="javascript:;"><i class="iconfont icon-user"></i>周杰伦</a></li> <li> <el-popconfirm title="确认退出吗?" confirm-button-text="确认" cancel-button-text="取消"> <template #reference> <a href="javascript:;">退出登录</a> </template> </el-popconfirm> </li> <li><a href="javascript:;">我的订单</a></li> <li><a href="javascript:;">会员中心</a></li> </template> <template v-else> <li><a href="javascript:;" @click="$router.push('/login')">请先登录</a></li> <li><a href="javascript:;">帮助中心</a></li> <li><a href="javascript:;">关于我们</a></li> </template> </ul> </div> </nav> </template>
02. 登录-表单校验实现
Note
为什么需要校验
作用:前端提前校验可以省去一些错误的请求提交 ,为后端节省接口压力。
表单数据 → 前端校验(过滤错误请求)→后端查询是否匹配
表单如何进行校验
ElementPlus 表单组件内置了表单校验功能,只需要按照组件要求配置必要参数即可(直接看文档)
思想:当功能很复杂时,通过多个组件各自负责某个小功能,再组合成一个大功能 是组件设计中的常用方法
表单校验步骤
按照接口字段 准备表单对象并绑定
按照产品要求 准备规则对象并绑定
指定表单域的校验字段名
把表单对象进行双向绑定
用户名:不能为空,字段名为 account
密码:不能为空且为 6-14 个字符,字段名为 password
同意协议:必选,字段名为 agree
修改 src/views/Login/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 <script setup> import { ref } from 'vue'; const form = ref({ account: '', password: '', }) const rules = { account: [ { required: true, message: '用户名不能为空', trigger: 'blur' } ], password: [ { required: true, message: '密码不能为空', trigger: 'blur' }, { min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' }, { max: 14, message: '密码长度不能多于 14 位', trigger: 'blur' } ] } </script> <template> <div> <header class="login-header"> <div class="container m-top-20"> <h1 class="logo"> <RouterLink to="/">小兔鲜</RouterLink> </h1> <RouterLink class="entry" to="/"> 进入网站首页 <i class="iconfont icon-angle-right"></i> <i class="iconfont icon-angle-right"></i> </RouterLink> </div> </header> <section class="login-section"> <div class="wrapper"> <nav> <a href="javascript:;">账户登录</a> </nav> <div class="account-box"> <div class="form"> <el-form :model="form" :rules="rules" label-position="right" label-width="60px" status-icon> <el-form-item prop="account" label="账户"> <el-input v-model="form.account" /> </el-form-item> <el-form-item prop="password" label="密码"> <el-input v-model="form.password" type="password"/> </el-form-item> <el-form-item label-width="22px"> <el-checkbox size="large"> 我已同意隐私条款和服务条款 </el-checkbox> </el-form-item> <el-button size="large" class="subBtn">点击登录</el-button> </el-form> </div> </div> </div> </section> <footer class="login-footer"> <div class="container"> <p> <a href="javascript:;">关于我们</a> <a href="javascript:;">帮助中心</a> <a href="javascript:;">售后服务</a> <a href="javascript:;">配送与验收</a> <a href="javascript:;">商务合作</a> <a href="javascript:;">搜索推荐</a> <a href="javascript:;">友情链接</a> </p> <p>CopyRight © 小兔鲜儿</p> </div> </footer> </div> </template>
03. 登录-表单校验-自定义校验规则
Note
自定义校验规则
ElementPlus 表单组件内置了初始的校验配置,应付简单的校验只需要通过配置即可,如果想要定制一些特殊的校验需求 ,可以使用自定义校验规则,格式如下:
1 2 3 4 5 6 7 { validator :(rule, val.,callback ) => { } }
校验逻辑:如果勾选了协议框,通过校验,如果没有勾选,不通过校验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 <script setup> import { ref } from 'vue'; const form = ref({ account: '', password: '', }) const rules = { account: [ { required: true, message: '用户名不能为空', trigger: 'blur' } ], password: [ { required: true, message: '密码不能为空', trigger: 'blur' }, { min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' }, { max: 14, message: '密码长度不能多于 14 位', trigger: 'blur' } ], agree: [ { validator: (rule, value, callback) => { if(value) { callback() } else { callback(new Error('请同意隐私条款和服务条款')) } } } ] } </script> <template> <div> <header class="login-header"> <div class="container m-top-20"> <h1 class="logo"> <RouterLink to="/">小兔鲜</RouterLink> </h1> <RouterLink class="entry" to="/"> 进入网站首页 <i class="iconfont icon-angle-right"></i> <i class="iconfont icon-angle-right"></i> </RouterLink> </div> </header> <section class="login-section"> <div class="wrapper"> <nav> <a href="javascript:;">账户登录</a> </nav> <div class="account-box"> <div class="form"> <el-form :model="form" :rules="rules" label-position="right" label-width="60px" status-icon> <el-form-item prop="account" label="账户"> <el-input v-model="form.account" /> </el-form-item> <el-form-item prop="password" label="密码"> <el-input v-model="form.password" type="password"/> </el-form-item> <el-form-item prop="agree" label-width="22px"> <el-checkbox v-model="form.agree" size="large"> 我已同意隐私条款和服务条款 </el-checkbox> </el-form-item> <el-button size="large" class="subBtn">点击登录</el-button> </el-form> </div> </div> </div> </section> <footer class="login-footer"> <div class="container"> <p> <a href="javascript:;">关于我们</a> <a href="javascript:;">帮助中心</a> <a href="javascript:;">售后服务</a> <a href="javascript:;">配送与验收</a> <a href="javascript:;">商务合作</a> <a href="javascript:;">搜索推荐</a> <a href="javascript:;">友情链接</a> </p> <p>CopyRight © 小兔鲜儿</p> </div> </footer> </div> </template>
04. 登录-表单校验-统一校验
Note
整个表单的内容验证
思考:每个表单域都有自己的校验触发事件,如果用户一上来就点击登录怎么办呢?
答:在点击登录时需要对所有需要校验的表单进行统一校验
1 2 3 4 5 6 7 8 formEl.validate ((valid ) => { if (valid) { console .log ('submit!' ) } else { console .log ('error submit!' ) return false } })
获取 form 组件实例 → 调用实例方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 <script setup> import { ref } from 'vue'; ... const formRef = ref(null); const doLogin = () => { formRef.value.validate((valid) => { console.log(valid); if (valid) { } }) } </script> <template> ... <section class="login-section"> <div class="wrapper"> <nav> <a href="javascript:;">账户登录</a> </nav> <div class="account-box"> <div class="form"> <el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="60px" status-icon> <el-form-item prop="account" label="账户"> <el-input v-model="form.account" /> </el-form-item> <el-form-item prop="password" label="密码"> <el-input v-model="form.password" type="password" /> </el-form-item> <el-form-item prop="agree" label-width="22px"> <el-checkbox v-model="form.agree" size="large"> 我已同意隐私条款和服务条款 </el-checkbox> </el-form-item> <el-button size="large" class="subBtn" @click="doLogin">点击登录</el-button> </el-form> </div> </div> </div> </section> ... </template>
05. 登录-基础功能实现
Note
登录业务流程
表单校验通过
封装登录接口
创建 src/apis/user.js
:
1 2 3 4 5 6 7 8 9 10 11 12 import httpInstance from '@/utils/http' ;export const loginAPI = ({ account, password } ) => { return httpInstance ({ url : '/login' , method : 'POST' , data : { account, password } }); }
调用登录接口
06. 登录-Pinia 管理用户数据
Note
为什么要用 Pinia 管理数据
由于用户数据的特殊性,在很多组件中都有可能进行共享 ,共享的数据使用 Pinia 管理会更加方便
如何使用 Pinia 管理数据
遵循理念:和数据相关的所有操作(state+action)都放到 Pinia 中,组件只负责触发 action 函数
component
→ Pinia (state + action)
创建 src/stores/user.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import { defineStore } from 'pinia' ;import { ref } from 'vue' ;import { loginAPI } from '@/apis/user' ;export const useUserStore = defineStore ('user' , () => { const userInfo = ref ({}); const getUserInfo = async ({ account, password } ) => { const res = await loginAPI ({ account, password }); userInfo.value = res.result ; }; return { userInfo, getUserInfo }; });
在 src/views/Login/index.vue
中使用 pinia 管理数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { useUserStore } from '@/stores/user' ;const userStore = useUserStore (); ...const formRef = ref (null );const doLogin = ( ) => { const { account, password } = form.value ; formRef.value .validate (async (valid) => { if (valid) { await userStore.getUserInfo ({ account, password }); ElMessage ({ type : 'success' , message : '登录成功' }); router.replace ({ path : '/' }); } }) }
07. 登录-Pinia 用户数据持久化
Note
持久化用户数据说明
用户数据中有一个关键的数据叫做 Token(用来标识当前用户是否登录) ,而 Token 持续一段时间才会过期
Pinia 的存储是基于内存的,刷新就丢失,为了保持登录状态 就要做到刷新不丢失,需要配合持久化进行存储
目的:保持 token 不丢失,保持登录状态
最终效果:操作 state 时会自动把用户数据在本地的 localStorage 也存一份,刷新的时候会从 localstorage 中先取
安装 pinia 中用于用户数据持久化的插件:
1 npm i pinia-plugin-persistedstate
在 src/main.js
中使用这个插件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import '@/styles/common.scss' import { lazyPlugin } from '@/directives' import { componentPlugin } from '@/components' import persistedState from 'pinia-plugin-persistedstate' const app = createApp (App )const pinia = createPinia (); pinia.use (persistedState); app.use (pinia); app.use (router) app.use (lazyPlugin) app.use (componentPlugin) app.mount ('#app' )
在 src/stores/user.js
中设置数据持久化(persist: true
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { defineStore } from 'pinia' ;import { ref } from 'vue' ;import { loginAPI } from '@/apis/user' ;export const useUserStore = defineStore ('user' , () => { const userInfo = ref ({}); const getUserInfo = async ({ account, password } ) => { const res = await loginAPI ({ account, password }); userInfo.value = res.result ; }; return { userInfo, getUserInfo }; }, { persist : true });
Note
关键步骤总结和插件运行机制
安装插件包
→ pinia 注册插件
→ 需要持久化的 store 进行配置
运行机制:
在设置 state 的时候会自动把数据同步给 localstorage,在获取 state 数据的时候会优先从 localStorage 中取
08. 登录-登录和非登录状态下的模版适配
编辑 src/views/Layout/components/LayoutNav.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 <script setup> import { useUserStore } from '@/stores/user'; const userStore = useUserStore(); </script> <template> <nav class="app-topnav"> <div class="container"> <ul> <template v-if="userStore.userInfo.token"> <li><a href="javascript:;"><i class="iconfont icon-user"></i>{{ userStore.userInfo.account }}</a></li> <li> <el-popconfirm title="确认退出吗?" confirm-button-text="确认" cancel-button-text="取消"> <template #reference> <a href="javascript:;">退出登录</a> </template> </el-popconfirm> </li> <li><a href="javascript:;">我的订单</a></li> <li><a href="javascript:;">会员中心</a></li> </template> <template v-else> <li><a href="javascript:;" @click="$router.push('/login')">请先登录</a></li> <li><a href="javascript:;">帮助中心</a></li> <li><a href="javascript:;">关于我们</a></li> </template> </ul> </div> </nav> </template>
09. 登录-请求拦截器携带 Token
Note
为什么要在请求拦截器携带 Token
Token 作为用户标识,在很多个接口中都需要携带 Token 才可以正确获取数据,所以需要在接口调用时携带 Token。另外,为了统一控制 采取请求拦截器携带的方案。
如何配置
Axios 请求拦截器可以在接口正式发起之前对请求参数做一些事情,通常 Token 数据会被注入到请求 header 中,格式按照后端要求的格式进行拼接处理 。
编辑 src/utils/http.js
中的请求拦截器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import axios from 'axios' ;import { ElMessage } from 'element-plus' ;import { useUserStore } from '@/stores/user' ;const http = axios.create ({ baseURL : 'http://pcapi-xiaotuxian-front-devtest.itheima.net' , timeout : 5000 }); http.interceptors .request .use (config => { const userStore = useUserStore (); const token = userStore.userInfo .token ; if (token) { config.headers .Authorization = `Bearer ${token} ` ; } return config; }, error => { return Promise .reject (error); }); http.interceptors .response .use (res => res.data , e => { ElMessage ({ type : "warning" , message : e.response .data .message }); return Promise .reject (e); });export default http;
如此做:
10. 登录-退出登录功能实现
Note
退出登录业务实现
点击退出登录弹确认框
点击确定按钮实现退出登录逻辑
登录逻辑包括:
清除当前用户信息
跳转到登录页面
在 src/stores/user.js
中设置清楚用户信息的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { defineStore } from 'pinia' ;import { ref } from 'vue' ;import { loginAPI } from '@/apis/user' ;export const useUserStore = defineStore ('user' , () => { const userInfo = ref ({}); const getUserInfo = async ({ account, password } ) => { const res = await loginAPI ({ account, password }); userInfo.value = res.result ; }; const clearUserInfo = ( ) => { userInfo.value = {}; }; return { userInfo, getUserInfo, clearUserInfo }; }, { persist : true });
编辑 src/Layout/components/LayoutNav.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 <script setup> import { useUserStore } from '@/stores/user'; import { useRouter } from 'vue-router'; const userStore = useUserStore(); const router = useRouter(); const confirm = () => { userStore.clearUserInfo(); router.push('/login'); }; </script> <template> <nav class="app-topnav"> <div class="container"> <ul> <template v-if="userStore.userInfo.token"> <li><a href="javascript:;"><i class="iconfont icon-user"></i>{{ userStore.userInfo.account }}</a> </li> <li> <el-popconfirm @confirm="confirm" title="确认退出吗?" confirm-button-text="确认" cancel-button-text="取消"> <template #reference> <a href="javascript:;">退出登录</a> </template> </el-popconfirm> </li> <li><a href="javascript:;">我的订单</a></li> <li><a href="javascript:;">会员中心</a></li> </template> <template v-else> <li><a href="javascript:;" @click="$router.push('/login')">请先登录</a></li> <li><a href="javascript:;">帮助中心</a></li> <li><a href="javascript:;">关于我们</a></li> </template> </ul> </div> </nav> </template>
11. 登录-Token 失效 401 拦截处理
Note
业务背景
Token 的有效性可以保持一定时间,如果用户一段时间不做任何操作,Token 就会失效,使用失效的 Token 再去请求一些接口,接口就会报 401 状态码错误,需要我们做额外处理。
两个需要思考的问题
我们能确定用户到底是在访问哪个接口时出现的 401 错误吗?在什么位置去拦截这个 401?
检测到 401 之后又该干什么呢?
解决方案-在 axios 响应拦截器做统一处理
失败回调中拦截 401
→ 清除掉过期的用户信息,跳转到登录页
编辑 src/utils/http.js
:
1 2 3 4 5 6 7 8 9 10 11 12 http.interceptors .response .use (res => res.data , e => { ElMessage ({ type : "warning" , message : e.response .data .message }); if (e.response .status === 401 ) { userStore.clearUserInfo (); router.push ('/login' ); } return Promise .reject (e); });
12. 购物车-流程梳理和本地加入购物车实现
Note
购物车业务逻辑梳理拆解
整个购物车的实现分为俩个大分支,本地购物车操作和接口购物车操作
由于购物车数据的特殊性,采取 Pinia 管理购物车列表数据并添加持久化缓存
本地购物车 - 加入购物车实现
创建 src/stores/cartStore.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { defineStore } from 'pinia' ;import { ref } from 'vue' ;export const useCartStore = defineStore ('cart' , () => { const cartList = ref ([]); const addCart = (goods ) => { const item = cartList.value .find ((item ) => goods.skuId === item.skuId ); if (item) { item.count ++; } else { cartList.value .push (goods) } } return { cartList, addCart } }, { persist : true , });
编辑 src/views/Detail/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 <script setup> import DetailHot from './components/DetailHot.vue'; import { getDetail } from '@/apis/detail'; import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; import { ElMessage } from 'element-plus'; import { useCartStore } from '@/stores/cartStore'; const cartStore = useCartStore(); const goods = ref({}); const route = useRoute(); const getGoods = async () => { const res = await getDetail(route.params.id); goods.value = res.result; }; onMounted(() => getGoods()); // sku 规格被操作时 let skuObj = {} const skuChange = (sku) => { skuObj = sku } // count const count = ref(1) const countChange = (count) => { console.log(count) } // 加入购物车 const addCart = () => { if (skuObj.skuId) { cartStore.addCart({ id: goods.value.id, name: goods.value.name, picture: goods.value.mainPictures[0], price: skuObj.price, count: count.value, skuId: skuObj.skuId, attrsText: skuObj.specsText, selected: true }); } else { ElMessage.error('请选择规格'); } } </script> <template> <div class="xtx-goods-page"> <div class="container" v-if="goods.details"> <div class="bread-container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: `/category/sub/${goods.categories[1].id}` }">{{ goods.categories[1].name }} </el-breadcrumb-item> <el-breadcrumb-item :to="{ path: `/category/sub/${goods.categories[0].id}` }">{{ goods.categories[0].name }} </el-breadcrumb-item> <el-breadcrumb-item>{{ goods.name }}</el-breadcrumb-item> </el-breadcrumb> </div> <!-- 商品信息 --> <div class="info-container"> <div> <div class="goods-info"> <div class="media"> <!-- 图片预览区 --> <ImageView :image-list="goods.mainPictures" /> <!-- 统计数量 --> <ul class="goods-sales"> <li> <p>销量人气</p> <p> {{ goods.salesCount }}+ </p> <p><i class="iconfont icon-task-filling"></i>销量人气</p> </li> <li> <p>商品评价</p> <p> {{ goods.commentCount }}+</p> <p><i class="iconfont icon-comment-filling"></i>查看评价</p> </li> <li> <p>收藏人气</p> <p> {{ goods.collectCount }}+</p> <p><i class="iconfont icon-favorite-filling"></i>收藏商品</p> </li> <li> <p>品牌信息</p> <p> {{ goods.brand.name }}</p> <p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p> </li> </ul> </div> <div class="spec"> <!-- 商品信息区 --> <p class="g-name">{{ goods.name }}</p> <p class="g-desc">{{ goods.desc }}</p> <p class="g-price"> <span>{{ goods.price }}</span> <span>{{ goods.oldPrice }}</span> </p> <div class="g-service"> <dl> <dt>促销</dt> <dd>12月好物放送,App领券购买直降120元</dd> </dl> <dl> <dt>服务</dt> <dd> <span>无忧退货</span> <span>快速退款</span> <span>免费包邮</span> <a href="javascript:;">了解详情</a> </dd> </dl> </div> <!-- sku组件 --> <XtxSku :goods="goods" @change="skuChange"/> <!-- 数据组件 --> <el-input-number v-model="count" @change="countChange"/> <!-- 按钮组件 --> <div> <el-button size="large" class="btn" @click="addCart"> 加入购物车 </el-button> </div> </div> </div> <div class="goods-footer"> <div class="goods-article"> <!-- 商品详情 --> <div class="goods-tabs"> <nav> <a>商品详情</a> </nav> <div class="goods-detail"> <!-- 属性 --> <ul class="attrs"> <li v-for="item in 3" :key="item.value"> <span class="dt">白色</span> <span class="dd">纯棉</span> </li> </ul> <!-- 图片 --> </div> </div> </div> <!-- 24热榜+专题推荐 --> <div class="goods-aside"> <DetailHot :hot-type="1" /> <DetailHot :hot-type="2" /> </div> </div> </div> </div> </div> </div> </template>
13. 购物车-本地-头部购物车列表渲染
14. 购物车-本地-头部购物车删除功能实现
15. 购物车-本地-头部购物车统计计算
Note
本地购物车 - 头部购物车列表渲染
准备头部购物车组件
→ 从 Pinia 中获取数据渲染列表
本地购物车 - 头部购物车统计计算
用什么来实现:计算属性
计算逻辑是什么:
商品总数计算逻辑:商品列表中的所有商品 count 累加之和
商品总价钱计算逻辑:商品列表中的所有商品的 count * price 累加之和
创建 src/views/Layout/components/HeaderCart.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 <script setup> import { useCartStore } from '@/stores/cartStore'; const store = useCartStore(); </script> <template> <div class="cart"> <a class="curr" href="javascript:;"> <i class="iconfont icon-cart"></i><em>{{ store.cartList.length }}</em> </a> <div class="layer"> <div class="list"> <div class="item" v-for="i in store.cartList" :key="i"> <RouterLink :to="`/detail/${i.id}`"> <img :src="i.picture" alt="" /> <div class="center"> <p class="name ellipsis-2"> {{ i.name }} </p> <p class="attr ellipsis">{{ i.attrsText }}</p> </div> <div class="right"> <p class="price">¥{{ i.price }}</p> <p class="count">x{{ i.count }}</p> </div> </RouterLink> <i class="iconfont icon-close-new" @click="store.delCart(i.skuId)"></i> </div> </div> <div class="foot"> <div class="total"> <p>共 {{ store.allCount }} 件商品</p> <p>¥ {{ store.allPrice.toFixed(2) }} </p> </div> <el-button size="large" type="primary">去购物车结算</el-button> </div> </div> </div> </template> <style scoped lang="scss"> .cart { width: 50px; position: relative; z-index: 600; .curr { height: 32px; line-height: 32px; text-align: center; position: relative; display: block; .icon-cart { font-size: 22px; } em { font-style: normal; position: absolute; right: 0; top: 0; padding: 1px 6px; line-height: 1; background: $helpColor; color: #fff; font-size: 12px; border-radius: 10px; font-family: Arial; } } &:hover { .layer { opacity: 1; transform: none; } } .layer { opacity: 0; transition: all 0.4s 0.2s; transform: translateY(-200px) scale(1, 0); width: 400px; height: 400px; position: absolute; top: 50px; right: 0; box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); background: #fff; border-radius: 4px; padding-top: 10px; &::before { content: ""; position: absolute; right: 14px; top: -10px; width: 20px; height: 20px; background: #fff; transform: scale(0.6, 1) rotate(45deg); box-shadow: -3px -3px 5px rgba(0, 0, 0, 0.1); } .foot { position: absolute; left: 0; bottom: 0; height: 70px; width: 100%; padding: 10px; display: flex; justify-content: space-between; background: #f8f8f8; align-items: center; .total { padding-left: 10px; color: #999; p { &:last-child { font-size: 18px; color: $priceColor; } } } } } .list { height: 310px; overflow: auto; padding: 0 10px; &::-webkit-scrollbar { width: 10px; height: 10px; } &::-webkit-scrollbar-track { background: #f8f8f8; border-radius: 2px; } &::-webkit-scrollbar-thumb { background: #eee; border-radius: 10px; } &::-webkit-scrollbar-thumb:hover { background: #ccc; } .item { border-bottom: 1px solid #f5f5f5; padding: 10px 0; position: relative; i { position: absolute; bottom: 38px; right: 0; opacity: 0; color: #666; transition: all 0.5s; } &:hover { i { opacity: 1; cursor: pointer; } } a { display: flex; align-items: center; img { height: 80px; width: 80px; } .center { padding: 0 10px; width: 200px; .name { font-size: 16px; } .attr { color: #999; padding-top: 5px; } } .right { width: 100px; padding-right: 20px; text-align: center; .price { font-size: 16px; color: $priceColor; } .count { color: #999; margin-top: 5px; font-size: 16px; } } } } } } </style>
在 src/views/components/LayoutHeader.vue
中渲染头部购物车:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <script setup> import { useCategoryStore } from '@/stores/category'; import HeaderCart from './HeaderCart.vue'; const categoryStore = useCategoryStore(); </script> <template> <header class='app-header'> <div class="container"> <h1 class="logo"> <RouterLink to="/">小兔鲜</RouterLink> </h1> <ul class="app-header-nav"> <li class="home" v-for="item in categoryStore.categoryList " :key="item.id"> <RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</RouterLink> </li> </ul> <div class="search"> <i class="iconfont icon-search"></i> <input type="text" placeholder="搜一搜"> </div> <!-- 头部购物车 --> <HeaderCart /> </div> </header> </template>
在 src/stores/cartStore.js
中编写删除购物车的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import { defineStore } from 'pinia' ;import { ref, computed } from 'vue' ;export const useCartStore = defineStore ('cart' , () => { const cartList = ref ([]); const addCart = (goods ) => { const item = cartList.value .find ((item ) => goods.skuId === item.skuId ); if (item) { item.count ++; } else { cartList.value .push (goods) } } const delCart = async (skuId ) => { if (isLogin.value ) { await delCartAPI ([skuId]); updateNewList (); } else { const idx = cartList.value .findIndex ((item ) => skuId === item.skuId ) cartList.value .splice (idx, 1 ) } } const allCount = computed (()=> cartList.value .reduce ((acc, cur ) => acc + cur.count , 0 )); const allPrice = computed (()=> cartList.value .reduce ((acc, cur ) => acc + cur.price * cur.count , 0 )); return { cartList, addCart, delCart, allCount, allPrice } }, { persist : true , });
day06
01. 购物车-本地-列表购物车基础数据渲染
创建 src/views/CartList/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 <script setup> import { useCartStore } from '@/stores/cartStore'; const store = useCartStore(); const cartList = store.cartList; const allCount = store.allCount; const allPrice = store.allPrice; </script> <template> <div class="xtx-cart-page"> <div class="container m-top-20"> <div class="cart"> <table> <thead> <tr> <th width="120"> <el-checkbox /> </th> <th width="400">商品信息</th> <th width="220">单价</th> <th width="180">数量</th> <th width="180">小计</th> <th width="140">操作</th> </tr> </thead> <!-- 商品列表 --> <tbody> <tr v-for="i in cartList" :key="i.id"> <td> <el-checkbox /> </td> <td> <div class="goods"> <RouterLink to="/"><img :src="i.picture" alt="" /></RouterLink> <div> <p class="name ellipsis"> {{ i.name }} </p> </div> </div> </td> <td class="tc"> <p>¥{{ i.price }}</p> </td> <td class="tc"> <el-input-number v-model="i.count" /> </td> <td class="tc"> <p class="f16 red">¥{{ (i.price * i.count).toFixed(2) }}</p> </td> <td class="tc"> <p> <el-popconfirm title="确认删除吗?" confirm-button-text="确认" cancel-button-text="取消" @confirm="delCart(i)"> <template #reference> <a href="javascript:;">删除</a> </template> </el-popconfirm> </p> </td> </tr> <tr v-if="cartList.length === 0"> <td colspan="6"> <div class="cart-none"> <el-empty description="购物车列表为空"> <el-button type="primary">随便逛逛</el-button> </el-empty> </div> </td> </tr> </tbody> </table> </div> <!-- 操作栏 --> <div class="action"> <div class="batch"> 共 {{ allCount }} 件商品,已选择 2 件,商品合计: <span class="red">¥ {{ allPrice.toFixed(2) }} </span> </div> <div class="total"> <el-button size="large" type="primary">下单结算</el-button> </div> </div> </div> </div> </template> <style scoped lang="scss"> .xtx-cart-page { margin-top: 20px; .cart { background: #fff; color: #666; table { border-spacing: 0; border-collapse: collapse; line-height: 24px; th, td { padding: 10px; border-bottom: 1px solid #f5f5f5; &:first-child { text-align: left; padding-left: 30px; color: #999; } } th { font-size: 16px; font-weight: normal; line-height: 50px; } } } .cart-none { text-align: center; padding: 120px 0; background: #fff; p { color: #999; padding: 20px 0; } } .tc { text-align: center; a { color: $xtxColor; } .xtx-numbox { margin: 0 auto; width: 120px; } } .red { color: $priceColor; } .green { color: $xtxColor; } .f16 { font-size: 16px; } .goods { display: flex; align-items: center; img { width: 100px; height: 100px; } >div { width: 280px; font-size: 16px; padding-left: 10px; .attr { font-size: 14px; color: #999; } } } .action { display: flex; background: #fff; margin-top: 20px; height: 80px; align-items: center; font-size: 16px; justify-content: space-between; padding: 0 30px; .xtx-checkbox { color: #999; } .batch { a { margin-left: 20px; } } .red { font-size: 18px; margin-right: 20px; font-weight: bold; } } .tit { color: #666; font-size: 16px; font-weight: normal; line-height: 50px; } } </style>
从 router/index.js
中配置路由:
1 2 3 4 5 6 ... { path : 'cartlist' , component : CartList } ...
02. 购物车-本地-列表购物车单选功能
Note
列表购物车-单选功能
核心思路:单选的核心思路就是始终把单选框的状态和 Pinia 中 store 对应的状态保持同步
注意事项:v-model
双向绑定指令不方便进行命令式的操作(因为后续还需要调用接口),所以把 v-model
回退到一般模式,也就是:model-value
和 @change
的配合实现
编辑 src/views/CartList/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <script setup> import { useCartStore } from '@/stores/cartStore'; const store = useCartStore(); const cartList = store.cartList; const allCount = store.allCount; const allPrice = store.allPrice; const singleCheck = (item, selected) => { console.log(item, selected); store.singleCheck(item.skuId, selected); }; </script> <template> ... <tr v-for="i in cartList" :key="i.id"> <td> <!-- 单选框 --> <el-checkbox :model-value="i.selected" @change="(selected) => singleCheck(i, selected)" /> </td> ... </template>
(selected) => singleCheck(i, selected)
这种写法可以传递额外的参数。
测试:
03. 购物车-本地-购物车列表全选功能
Note
列表购物车-全选
核心思路:
操作单选决定全选:只有当 cartList 中的所有项都为 true 时,全选状态才为 true
操作全选决定单选:cartList 中的所有项的 selected 都要跟着一起变
编辑 src/views/CartList/index.vue
:
1 2 3 const allCheck = (selected ) => { store.allCheck (selected); }
1 2 3 <th width="120"> <el-checkbox :model-value="store.isAll" @change="allCheck"/> </th>
编辑 src/stores/cartStore.js
:
1 2 3 4 5 6 7 8 9 10 11 const isAll = computed (() => cartList.value .every ((item ) => item.selected ));const allCheck = (selected ) => { cartList.value .forEach ((item ) => item.selected = selected); }return { ... allCheck, isAll }
04. 购物车-本地-购物车列表统计数据实现
Note
列表购物车-统计数据实现
在 stores/cartStore/js
中定义计算逻辑:
已选择数量 = cartList 中所有 selected 字段为 true 项的 count 之和
1 const selectedCount = computed (() => cartList.value .filter ((item ) => item.selected ).reduce ((acc, cur ) => acc + cur.count , 0 ));
商品合计 = cartlist 中所有 selected 字段为 true 项的 count * price 之和
1 const selectedPrice = computed (() => cartList.value .filter ((item ) => item.selected ).reduce ((acc, cur ) => acc + cur.price * cur.count , 0 ));
在 src/views/CartList/index.vue
中编辑操作栏:
1 2 3 4 5 6 7 8 9 10 <!-- 操作栏 --> <div class="action"> <div class="batch"> 共 {{ store.allCount }} 件商品,已选择 {{ store.selectedCount }} 件,商品合计: <span class="red">¥ {{ store.selectedPrice.toFixed(2) }} </span> </div> <div class="total"> <el-button size="large" type="primary">下单结算</el-button> </div> </div>
05. 购物车-接口-加入购物车
Note
接口购物车-整体业务流程回顾
结论
到目前为止,购物车在非登录状态下的各种操作都已经 ok 了,包括 action 的封装、触发、参数传递 ,剩下的事情就是在 action 中做登录状态的分支判断,补充登录状态下的接口操作逻辑 即可
接口购物车-加入购物车
创建 src/apis/cart.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import httpInstance from '@/utils/http' ;export const insertCartAPI = ({ skuId, count } ) => { return httpInstance ({ url : '/member/cart' , method : 'POST' , data : { skuId, count } }); }export const findNewCartListAPI = ( ) => { return httpInstance ({ url : 'member/cart' }) }
编写 stores/cartStore.js
,如果登陆时,调用后端接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 export const useCartStore = defineStore ('cart' , () => {const userStore = useUserStore ();const isLogin = computed (() => userStore.userInfo .token );const cartList = ref ([]);const addCart = async (goods ) => { const { skuId, count } = goods; if (isLogin.value ) { await insertCartAPI ({ skuId, count }); const res = await findNewCartListAPI (); cartList.value = res.result ; } else { const item = cartList.value .find ((item ) => goods.skuId === item.skuId ); if (item) { item.count ++; } else { cartList.value .push (goods) } } } ...
06. 购物车-接口-删除购物车
Note
接口购物车-删除购物车
在 src/apis/cart.js
中封装删除购物车的 API:
1 2 3 4 5 6 7 8 9 export const delCartAPI = (ids ) => { return httpInstance ({ url : '/member/cart' , method : 'DELETE' , data : { ids } }) }
在 src/stores/cartStore.js
中编写逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const addCart = async (goods ) => { const { skuId, count } = goods; if (isLogin.value ) { await insertCartAPI ({ skuId, count }); updateNewList (); } else { const item = cartList.value .find ((item ) => goods.skuId === item.skuId ); if (item) { item.count ++; } else { cartList.value .push (goods) } } }const delCart = async (skuId ) => { if (isLogin.value ) { await delCartAPI (skuId); updateNewList (); } else { const index = cartList.value .findIndex ((item ) => item.skuId === skuId); cartList.value .splice (index, 1 ); } }
为了保持命名的规范化,将 stores/
下的文件末尾全部加上“Store”。
07. 退出登录-清空购物车数据
Note
业务需求
在用户退出登录时,除了清除用户信息之外,也需要把购物车数据清空
cartStroe.js
里写清空购物车的逻辑:
1 2 3 const clearCart = ( ) => { cartList.value = []; }
userStore.js
中在退出时调用清除购物车的 action:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import { defineStore } from 'pinia' ;import { ref } from 'vue' ;import { loginAPI } from '@/apis/user' ;import { useCartStore } from './cartStore' ;export const useUserStore = defineStore ('user' , () => { const userInfo = ref ({}); const cartStore = useCartStore (); const getUserInfo = async ({ account, password } ) => { const res = await loginAPI ({ account, password }); userInfo.value = res.result ; }; const clearUserInfo = ( ) => { userInfo.value = {}; cartStore.clearCart (); }; return { userInfo, getUserInfo, clearUserInfo }; }, { persist : true });
08. 购物车-合并本地购物车到服务器
Note
合并购物车业务实现
问:用户在非登录时进行的所有购物车操作,我们的服务器能知道吗?
答:不能!不能的话不是白操作了吗?还本地购物车的意义在哪?
解决办法:在用户登录时 ,把本地的购物车 数据和服务端购物车 数据进行合并操作
在 apis/cart.js
封装合并购物车的 API
1 2 3 4 5 6 7 export const mergeCartAPI = (data ) => { return httpInstance ({ url : '/member/cart/merge' , method : 'POST' , data }) }
在 stores/userStore.js
中调用这个 API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const getUserInfo = async ({ account, password } ) => { console .log ('login' , account, password); const res = await loginAPI ({ account, password }); userInfo.value = res.result ; mergeCartAPI (cartStore.cartList .map (item => { return { skuId : item.skuId , selected : item.selected , count : item.count } })); cartStore.updateNewList (); };
09. 结算-路由配置和基础数据渲染
router/index.js
中创建 checkout
页面:
1 2 3 4 { path : 'checkout' , component : Checkout }
在 src/views/CarList/index.vue
中的下单结算中设置路由跳转:
1 2 3 <div class="total"> <el-button size="large" type="primary" @click="$router.push('/checkout')">下单结算</el-button> </div>
在 src/views/apis/checkout.js
中封装 API:
1 2 3 4 5 6 7 import httpInstance from '@/utils/http' ;export const getCheckInfoAPI = ( ) => { return httpInstance ({ url : '/member/order/pre' , }) }
创建 src/views/Checkout/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 <script setup> import { getCheckInfoAPI } from '@/apis/checkout'; import { onMounted, ref } from 'vue'; const checkInfo = ref({}) // 订单对象 const curAddress = ref({}) const getCheckInfo = async () => { const res = await getCheckInfoAPI(); checkInfo.value = res.result; console.log(checkInfo.value) const item = checkInfo.value.userAddresses.find(item => item.isDefault === 0); curAddress.value = item; } onMounted(()=> getCheckInfo()) </script> <template> <div class="xtx-pay-checkout-page"> <div class="container"> <div class="wrapper"> <!-- 收货地址 --> <h3 class="box-title">收货地址</h3> <div class="box-body"> <div class="address"> <div class="text"> <div class="none" v-if="!curAddress">您需要先添加收货地址才可提交订单。</div> <ul v-else> <li><span>收<i />货<i />人:</span>{{ curAddress.receiver }}</li> <li><span>联系方式:</span>{{ curAddress.contact }}</li> <li><span>收货地址:</span>{{ curAddress.fullLocation }} {{ curAddress.address }}</li> </ul> </div> <div class="action"> <el-button size="large" @click="toggleFlag = true">切换地址</el-button> <el-button size="large" @click="addFlag = true">添加地址</el-button> </div> </div> </div> <!-- 商品信息 --> <h3 class="box-title">商品信息</h3> <div class="box-body"> <table class="goods"> <thead> <tr> <th width="520">商品信息</th> <th width="170">单价</th> <th width="170">数量</th> <th width="170">小计</th> <th width="170">实付</th> </tr> </thead> <tbody> <tr v-for="i in checkInfo.goods" :key="i.id"> <td> <a href="javascript:;" class="info"> <img :src="i.picture" alt=""> <div class="right"> <p>{{ i.name }}</p> <p>{{ i.attrsText }}</p> </div> </a> </td> <td>¥{{ i.price }}</td> <td>{{ i.count }}</td> <td>¥{{ i.totalPrice }}</td> <td>¥{{ i.totalPayPrice }}</td> </tr> </tbody> </table> </div> <!-- 配送时间 --> <h3 class="box-title">配送时间</h3> <div class="box-body"> <a class="my-btn active" href="javascript:;">不限送货时间:周一至周日</a> <a class="my-btn" href="javascript:;">工作日送货:周一至周五</a> <a class="my-btn" href="javascript:;">双休日、假日送货:周六至周日</a> </div> <!-- 支付方式 --> <h3 class="box-title">支付方式</h3> <div class="box-body"> <a class="my-btn active" href="javascript:;">在线支付</a> <a class="my-btn" href="javascript:;">货到付款</a> <span style="color:#999">货到付款需付5元手续费</span> </div> <!-- 金额明细 --> <h3 class="box-title">金额明细</h3> <div class="box-body"> <div class="total"> <dl> <dt>商品件数:</dt> <dd>{{ checkInfo.summary?.goodsCount }}件</dd> </dl> <dl> <dt>商品总价:</dt> <dd>¥{{ checkInfo.summary?.totalPrice.toFixed(2) }}</dd> </dl> <dl> <dt>运<i></i>费:</dt> <dd>¥{{ checkInfo.summary?.postFee.toFixed(2) }}</dd> </dl> <dl> <dt>应付总额:</dt> <dd class="price">{{ checkInfo.summary?.totalPayPrice.toFixed(2) }}</dd> </dl> </div> </div> <!-- 提交订单 --> <div class="submit"> <el-button type="primary" size="large">提交订单</el-button> </div> </div> </div> </div> <!-- 切换地址 --> <!-- 添加地址 --> </template> <style scoped lang="scss"> .xtx-pay-checkout-page { margin-top: 20px; .wrapper { background: #fff; padding: 0 20px; .box-title { font-size: 16px; font-weight: normal; padding-left: 10px; line-height: 70px; border-bottom: 1px solid #f5f5f5; } .box-body { padding: 20px 0; } } } .address { border: 1px solid #f5f5f5; display: flex; align-items: center; .text { flex: 1; min-height: 90px; display: flex; align-items: center; .none { line-height: 90px; color: #999; text-align: center; width: 100%; } >ul { flex: 1; padding: 20px; li { line-height: 30px; span { color: #999; margin-right: 5px; >i { width: 0.5em; display: inline-block; } } } } >a { color: $xtxColor; width: 160px; text-align: center; height: 90px; line-height: 90px; border-right: 1px solid #f5f5f5; } } .action { width: 420px; text-align: center; .btn { width: 140px; height: 46px; line-height: 44px; font-size: 14px; &:first-child { margin-right: 10px; } } } } .goods { width: 100%; border-collapse: collapse; border-spacing: 0; .info { display: flex; text-align: left; img { width: 70px; height: 70px; margin-right: 20px; } .right { line-height: 24px; p { &:last-child { color: #999; } } } } tr { th { background: #f5f5f5; font-weight: normal; } td, th { text-align: center; padding: 20px; border-bottom: 1px solid #f5f5f5; &:first-child { border-left: 1px solid #f5f5f5; } &:last-child { border-right: 1px solid #f5f5f5; } } } } .my-btn { width: 228px; height: 50px; border: 1px solid #e4e4e4; text-align: center; line-height: 48px; margin-right: 25px; color: #666666; display: inline-block; &.active, &:hover { border-color: $xtxColor; } } .total { dl { display: flex; justify-content: flex-end; line-height: 50px; dt { i { display: inline-block; width: 2em; } } dd { width: 240px; text-align: right; padding-right: 70px; &.price { font-size: 20px; color: $priceColor; } } } } .submit { text-align: right; padding: 60px; border-top: 1px solid #f5f5f5; } .addressWrapper { max-height: 500px; overflow-y: auto; } .text { flex: 1; min-height: 90px; display: flex; align-items: center; &.item { border: 1px solid #f5f5f5; margin-bottom: 10px; cursor: pointer; &.active, &:hover { border-color: $xtxColor; background: lighten($xtxColor, 50%); } >ul { padding: 10px; font-size: 14px; line-height: 30px; } } } </style>
10. 结算-地址切换-打开弹框交互实现
Note
地址切换交互需求分析
打开弹框交互:点击切换地址按钮,打开弹框,回显用户可选地址列表
切换地址交互:点击切换地址,点击确定按钮,激活地址替换默认收货地址
打开弹框交互实现
编辑 src/views/Checkout/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <script setup> ... const showDialog = ref(false) </script> <template> ... <div class="action"> <el-button size="large" @click="showDialog = true">切换地址</el-button> <el-button size="large" @click="addFlag = true">添加地址</el-button> </div> ... <!-- 切换地址 --> <el-dialog v-model="showDialog" title="切换收货地址" width="30%" center> <div class="addressWrapper"> <div class="text item" v-for="item in checkInfo.userAddresses" :key="item.id"> <ul> <li><span>收<i />货<i />人:</span>{{ item.receiver }} </li> <li><span>联系方式:</span>{{ item.contact }}</li> <li><span>收货地址:</span>{{ item.fullLocation + item.address }}</li> </ul> </div> </div> <template #footer> <span class="dialog-footer"> <el-button>取消</el-button> <el-button type="primary">确定</el-button> </span> </template> </el-dialog> ... </template>
11. 结算-切换地址-地址激活交互实现
Note
地址激活交互实现
原理:地址切换是我们经常遇到的 tab 切换类
需求,这类需求的实现逻辑都是相似的
点击时记录一个当前激活地址对象 activeAddress,点击哪个地址就把哪个地址对象记录下来
通过动态类名:class 控制激活样式类型 active 是否存在 ,判断条件为:激活地址对象的 id === 当前项 id
编辑 src/views/Checkout/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 <script setup> import { getCheckInfoAPI } from '@/apis/checkout'; import { onMounted, ref } from 'vue'; const checkInfo = ref({}) // 订单对象 const curAddress = ref({}) const getCheckInfo = async () => { const res = await getCheckInfoAPI(); checkInfo.value = res.result; const item = checkInfo.value.userAddresses.find(item => item.isDefault === 0); curAddress.value = item; } onMounted(() => getCheckInfo()) const showDialog = ref(false) const activeAddress = ref({}); const switchAddress = (item) => { activeAddress.value = item; } const confirm = () => { curAddress.value = activeAddress.value; showDialog.value = false; activeAddress.value = {}; } </script> <template> <div class="xtx-pay-checkout-page"> <div class="container"> <div class="wrapper"> <!-- 收货地址 --> <h3 class="box-title">收货地址</h3> <div class="box-body"> <div class="address"> <div class="text"> <div class="none" v-if="!curAddress">您需要先添加收货地址才可提交订单。</div> <ul v-else> <li><span>收<i />货<i />人:</span>{{ curAddress.receiver }}</li> <li><span>联系方式:</span>{{ curAddress.contact }}</li> <li><span>收货地址:</span>{{ curAddress.fullLocation }} {{ curAddress.address }}</li> </ul> </div> <div class="action"> <el-button size="large" @click="showDialog = true; activeAddress = curAddress;">切换地址</el-button> <el-button size="large" @click="addFlag = true">添加地址</el-button> </div> </div> </div> <!-- 商品信息 --> <h3 class="box-title">商品信息</h3> <div class="box-body"> <table class="goods"> <thead> <tr> <th width="520">商品信息</th> <th width="170">单价</th> <th width="170">数量</th> <th width="170">小计</th> <th width="170">实付</th> </tr> </thead> <tbody> <tr v-for="i in checkInfo.goods" :key="i.id"> <td> <a href="javascript:;" class="info"> <img :src="i.picture" alt=""> <div class="right"> <p>{{ i.name }}</p> <p>{{ i.attrsText }}</p> </div> </a> </td> <td>¥{{ i.price }}</td> <td>{{ i.count }}</td> <td>¥{{ i.totalPrice }}</td> <td>¥{{ i.totalPayPrice }}</td> </tr> </tbody> </table> </div> <!-- 配送时间 --> <h3 class="box-title">配送时间</h3> <div class="box-body"> <a class="my-btn active" href="javascript:;">不限送货时间:周一至周日</a> <a class="my-btn" href="javascript:;">工作日送货:周一至周五</a> <a class="my-btn" href="javascript:;">双休日、假日送货:周六至周日</a> </div> <!-- 支付方式 --> <h3 class="box-title">支付方式</h3> <div class="box-body"> <a class="my-btn active" href="javascript:;">在线支付</a> <a class="my-btn" href="javascript:;">货到付款</a> <span style="color:#999">货到付款需付5元手续费</span> </div> <!-- 金额明细 --> <h3 class="box-title">金额明细</h3> <div class="box-body"> <div class="total"> <dl> <dt>商品件数:</dt> <dd>{{ checkInfo.summary?.goodsCount }}件</dd> </dl> <dl> <dt>商品总价:</dt> <dd>¥{{ checkInfo.summary?.totalPrice.toFixed(2) }}</dd> </dl> <dl> <dt>运<i></i>费:</dt> <dd>¥{{ checkInfo.summary?.postFee.toFixed(2) }}</dd> </dl> <dl> <dt>应付总额:</dt> <dd class="price">{{ checkInfo.summary?.totalPayPrice.toFixed(2) }}</dd> </dl> </div> </div> <!-- 提交订单 --> <div class="submit"> <el-button type="primary" size="large">提交订单</el-button> </div> </div> </div> </div> <!-- 切换地址 --> <el-dialog v-model="showDialog" title="切换收货地址" width="30%" center> <div class="addressWrapper"> <div class="text item" :class="{ active: activeAddress.id === item.id }" @click="switchAddress(item)" v-for="item in checkInfo.userAddresses" :key="item.id"> <ul> <li><span>收<i />货<i />人:</span>{{ item.receiver }} </li> <li><span>联系方式:</span>{{ item.contact }}</li> <li><span>收货地址:</span>{{ item.fullLocation + item.address }}</li> </ul> </div> </div> <template #footer> <span class="dialog-footer"> <el-button @click="showDialog = false">取消</el-button> <el-button type="primary" @click="confirm">确定</el-button> </span> </template> </el-dialog> <!-- 添加地址 --> </template>
12. 结算-生成订单功能实现
Note
业务需求说明
确定结算信息没有问题之后,点击提交订单按钮,需要做以下俩个事情:
调用接口生成订单 id,并且携带 id 跳转到支付页
调用更新购物车列表接口,更新购物车状态
准备支付页路由
编辑 src/router/index.js
:
1 2 3 4 { path : 'pay' , component : Pay }
创建 src/views/Pay/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 <script setup> const payInfo = {} </script> <template> <div class="xtx-pay-page"> <div class="container"> <!-- 付款信息 --> <div class="pay-info"> <span class="icon iconfont icon-queren2"></span> <div class="tip"> <p>订单提交成功!请尽快完成支付。</p> <p>支付还剩 <span>24分30秒</span>, 超时后将取消订单</p> </div> <div class="amount"> <span>应付总额:</span> <span>¥{{ payInfo.payMoney?.toFixed(2) }}</span> </div> </div> <!-- 付款方式 --> <div class="pay-type"> <p class="head">选择以下支付方式付款</p> <div class="item"> <p>支付平台</p> <a class="btn wx" href="javascript:;"></a> <a class="btn alipay" :href="payUrl"></a> </div> <div class="item"> <p>支付方式</p> <a class="btn" href="javascript:;">招商银行</a> <a class="btn" href="javascript:;">工商银行</a> <a class="btn" href="javascript:;">建设银行</a> <a class="btn" href="javascript:;">农业银行</a> <a class="btn" href="javascript:;">交通银行</a> </div> </div> </div> </div> </template> <style scoped lang="scss"> .xtx-pay-page { margin-top: 20px; } .pay-info { background: #fff; display: flex; align-items: center; height: 240px; padding: 0 80px; .icon { font-size: 80px; color: #1dc779; } .tip { padding-left: 10px; flex: 1; p { &:first-child { font-size: 20px; margin-bottom: 5px; } &:last-child { color: #999; font-size: 16px; } } } .amount { span { &:first-child { font-size: 16px; color: #999; } &:last-child { color: $priceColor; font-size: 20px; } } } } .pay-type { margin-top: 20px; background-color: #fff; padding-bottom: 70px; p { line-height: 70px; height: 70px; padding-left: 30px; font-size: 16px; &.head { border-bottom: 1px solid #f5f5f5; } } .btn { width: 150px; height: 50px; border: 1px solid #e4e4e4; text-align: center; line-height: 48px; margin-left: 30px; color: #666666; display: inline-block; &.active, &:hover { border-color: $xtxColor; } &.alipay { background: url(https://cdn.cnbj1.fds.api.mi-img.com/mi-mall/7b6b02396368c9314528c0bbd85a2e06.png) no-repeat center / contain; } &.wx { background: url(https://cdn.cnbj1.fds.api.mi-img.com/mi-mall/c66f98cff8649bd5ba722c2e8067c6ca.jpg) no-repeat center / contain; } } } </style>
封装生成订单接口
编辑 src/apis/checkout.js
:
1 2 3 4 5 6 7 export const createOrderAPI = (data ) => { return httpInstance ({ url : '/member/order' , method : 'POST' , data }) }
点击按钮调用接口,得到订单 id,携带 id 完成路由跳转
编辑 src/views/Checkout/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 const createOrder = async ( ) => { const res = await createOrderAPI ({ deliveryTimeType : 1 , payType : 1 , payChannel : 1 , buyerMessage : '' , goods : checkInfo.value .goods .map (item => { return { skuId : item.skuId , count : item.count } }), addressId : curAddress.value .id }) const orderId = res.result .id router.push ({ path : '/pay' , query : { id : orderId } }) cartStore.updateNewList () }
13. 支付-渲染基础数据
Note
渲染基础数据
支付页有俩个关键数据,一个是要支付的钱数 ,一个是倒计时数据 (超时不支付商品释放)
封装获取订单详情的接口
创建 src/apis/pay.js
:
1 2 3 4 5 6 7 import httpInstance from '@/utils/http' ;export const getOrderAPI = (id ) => { return httpInstance ({ url : `/member/order/${id} ` }) }
获取关键数据并渲染
编辑 src/views/Checkout/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 <script setup> import { getCheckInfoAPI, createOrderAPI } from '@/apis/checkout'; import { onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; import { useCartStore } from '@/stores/cartStore'; const cartStore = useCartStore(); const router = useRouter(); const checkInfo = ref({}) // 订单对象 const curAddress = ref({}) const getCheckInfo = async () => { const res = await getCheckInfoAPI(); checkInfo.value = res.result; const item = checkInfo.value.userAddresses.find(item => item.isDefault === 0); curAddress.value = item; } onMounted(() => getCheckInfo()) const showDialog = ref(false) const activeAddress = ref({}); const switchAddress = (item) => { activeAddress.value = item; } const confirm = () => { curAddress.value = activeAddress.value; showDialog.value = false; activeAddress.value = {}; } // 创建订单 const createOrder = async () => { const res = await createOrderAPI({ deliveryTimeType: 1, payType: 1, payChannel: 1, buyerMessage: '', goods: checkInfo.value.goods.map(item => { return { skuId: item.skuId, count: item.count } }), addressId: curAddress.value.id }) const orderId = res.result.id router.push({ path: '/pay', query: { id: orderId } }) // 更新购物车 cartStore.updateNewList() } </script> <template> <div class="xtx-pay-checkout-page"> <div class="container"> <div class="wrapper"> <!-- 收货地址 --> <h3 class="box-title">收货地址</h3> <div class="box-body"> <div class="address"> <div class="text"> <div class="none" v-if="!curAddress">您需要先添加收货地址才可提交订单。</div> <ul v-else> <li><span>收<i />货<i />人:</span>{{ curAddress.receiver }}</li> <li><span>联系方式:</span>{{ curAddress.contact }}</li> <li><span>收货地址:</span>{{ curAddress.fullLocation }} {{ curAddress.address }}</li> </ul> </div> <div class="action"> <el-button size="large" @click="showDialog = true; activeAddress = curAddress;">切换地址</el-button> <el-button size="large" @click="addFlag = true">添加地址</el-button> </div> </div> </div> <!-- 商品信息 --> <h3 class="box-title">商品信息</h3> <div class="box-body"> <table class="goods"> <thead> <tr> <th width="520">商品信息</th> <th width="170">单价</th> <th width="170">数量</th> <th width="170">小计</th> <th width="170">实付</th> </tr> </thead> <tbody> <tr v-for="i in checkInfo.goods" :key="i.id"> <td> <a href="javascript:;" class="info"> <img :src="i.picture" alt=""> <div class="right"> <p>{{ i.name }}</p> <p>{{ i.attrsText }}</p> </div> </a> </td> <td>¥{{ i.price }}</td> <td>{{ i.count }}</td> <td>¥{{ i.totalPrice }}</td> <td>¥{{ i.totalPayPrice }}</td> </tr> </tbody> </table> </div> <!-- 配送时间 --> <h3 class="box-title">配送时间</h3> <div class="box-body"> <a class="my-btn active" href="javascript:;">不限送货时间:周一至周日</a> <a class="my-btn" href="javascript:;">工作日送货:周一至周五</a> <a class="my-btn" href="javascript:;">双休日、假日送货:周六至周日</a> </div> <!-- 支付方式 --> <h3 class="box-title">支付方式</h3> <div class="box-body"> <a class="my-btn active" href="javascript:;">在线支付</a> <a class="my-btn" href="javascript:;">货到付款</a> <span style="color:#999">货到付款需付5元手续费</span> </div> <!-- 金额明细 --> <h3 class="box-title">金额明细</h3> <div class="box-body"> <div class="total"> <dl> <dt>商品件数:</dt> <dd>{{ checkInfo.summary?.goodsCount }}件</dd> </dl> <dl> <dt>商品总价:</dt> <dd>¥{{ checkInfo.summary?.totalPrice.toFixed(2) }}</dd> </dl> <dl> <dt>运<i></i>费:</dt> <dd>¥{{ checkInfo.summary?.postFee.toFixed(2) }}</dd> </dl> <dl> <dt>应付总额:</dt> <dd class="price">¥{{ checkInfo.summary?.totalPayPrice.toFixed(2) }}</dd> </dl> </div> </div> <!-- 提交订单 --> <div class="submit"> <el-button @click="createOrder" type="primary" size="large">提交订单</el-button> </div> </div> </div> </div> <!-- 切换地址 --> <el-dialog v-model="showDialog" title="切换收货地址" width="30%" center> <div class="addressWrapper"> <div class="text item" :class="{ active: activeAddress.id === item.id }" @click="switchAddress(item)" v-for="item in checkInfo.userAddresses" :key="item.id"> <ul> <li><span>收<i />货<i />人:</span>{{ item.receiver }} </li> <li><span>联系方式:</span>{{ item.contact }}</li> <li><span>收货地址:</span>{{ item.fullLocation + item.address }}</li> </ul> </div> </div> <template #footer> <span class="dialog-footer"> <el-button @click="showDialog = false">取消</el-button> <el-button type="primary" @click="confirm">确定</el-button> </span> </template> </el-dialog> <!-- 添加地址 --> </template>
14. 支付-实现支付功能
Note
支付业务流程
作为前端,只需要处理框选中的逻辑。
编辑 src/views/Pay/index.vue
:
1 2 3 4 5 const baseURL = 'http://pcapi-xiaotuxian-front-devtest.itheima.net/' const backURL = 'http://127.0.0.1:5173/paycallback' const redirectUrl = encodeURIComponent (backURL)const payUrl = `${baseURL} pay/aliPay?orderId=${route.query.id} &redirect=${redirectUrl} `
支付宝沙箱账号信息:
但是现在好像付款不了了……囧
15. 支付-支付结果展示
Note
业务需求理解
配置 paycallback 路由
编辑 src/router/index.js
:
1 2 3 4 { path : 'paycallback' , component : PayBack }
根据支付结果适配支付状态
创建并编辑 src/views/PayBack/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 <script setup> import { getOrderAPI } from '@/apis/pay'; import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; const route = useRoute(); const orderInfo = ref({}); const getOrderInfo = async () => { const res = await getOrderAPI(route.query.orderId); orderInfo.value = res.result; } onMounted(() => getOrderInfo()); </script> <template> <div class="xtx-pay-page"> <div class="container"> <!-- 支付结果 --> <div class="pay-result"> <span class="iconfont icon-queren2 green" v-if="$route.query.payResult === 'true'"></span> <span class="iconfont icon-shanchu red" v-else></span> <p class="tit">支付{{$route.query.payResult === 'true' ?'成功':'失败'}}</p> <p class="tip">我们将尽快为您发货,收货期间请保持手机畅通</p> <p>支付方式:<span>支付宝</span></p> <p>支付金额:<span>¥{{ orederInfo?.payMoney?.toFixed(2) }}</span></p> <div class="btn"> <el-button type="primary" style="margin-right:20px">查看订单</el-button> <el-button>进入首页</el-button> </div> <p class="alert"> <span class="iconfont icon-tip"></span> 温馨提示:小兔鲜儿不会以订单异常、系统升级为由要求您点击任何网址链接进行退款操作,保护资产、谨慎操作。 </p> </div> </div> </div> </template> <style scoped lang="scss"> .pay-result { padding: 100px 0; background: #fff; text-align: center; margin-top: 20px; >.iconfont { font-size: 100px; } .green { color: #1dc779; } .red { color: $priceColor; } .tit { font-size: 24px; } .tip { color: #999; } p { line-height: 40px; font-size: 16px; } .btn { margin-top: 50px; } .alert { font-size: 12px; color: #999; margin-top: 50px; } } </style>
16. 支付-封装倒计时函数
安装日期格式化库:
创建并编辑 src/composables/useCountDown.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import { computed, onUnmounted, ref } from 'vue' import dayjs from 'dayjs' export const useCountDown = ( ) => { let timer = null const time = ref (0 ) const formatTime = computed (() => dayjs.unix (time.value ).format ('mm分ss秒' )) const start = (currentTime ) => { time.value = currentTime timer = setInterval (() => { time.value -- }, 1000 ) } onUnmounted (() => { timer && clearInterval (timer) }) return { formatTime, start } }
更新 src/views/Pay/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 <script setup> import { getOrderAPI } from '@/apis/pay'; import { onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; import { useCountDown } from '@/composables/useCountDown'; const { formatTime, start } = useCountDown(); const route = useRoute() const payInfo = ref({}) const getPayInfo = async () => { const res = await getOrderAPI(route.query.id) payInfo.value = res.result // 初始化倒计时秒数 start(res.result.countdown) } onMounted(() => getPayInfo()) // 支付地址 const baseURL = 'http://pcapi-xiaotuxian-front-devtest.itheima.net/' const backURL = 'http://127.0.0.1:5173/paycallback' const redirectUrl = encodeURIComponent(backURL) const payUrl = `${baseURL}pay/aliPay?orderId=${route.query.id}&redirect=${redirectUrl}` </script> <template> <div class="xtx-pay-page"> <div class="container"> <!-- 付款信息 --> <div class="pay-info"> <span class="icon iconfont icon-queren2"></span> <div class="tip"> <p>订单提交成功!请尽快完成支付。</p> <p>支付还剩 <span>{{ formatTime }}</span>, 超时后将取消订单</p> </div> <div class="amount"> <span>应付总额:</span> <span>¥{{ payInfo?.totalMoney?.toFixed(2) }}</span> </div> </div> <!-- 付款方式 --> <div class="pay-type"> <p class="head">选择以下支付方式付款</p> <div class="item"> <p>支付平台</p> <a class="btn wx" href="javascript:;"></a> <a class="btn alipay" :href="payUrl"></a> </div> <div class="item"> <p>支付方式</p> <a class="btn" href="javascript:;">招商银行</a> <a class="btn" href="javascript:;">工商银行</a> <a class="btn" href="javascript:;">建设银行</a> <a class="btn" href="javascript:;">农业银行</a> <a class="btn" href="javascript:;">交通银行</a> </div> </div> </div> </div> </template>
day07
01. 会员中心-整体功能梳理和路由配置
Note
整体功能梳理
个人中心 - 个人信息和猜你喜欢数据渲染
我的订单 - 各种状态下的订单列表展示
路由配置(包括三级路由配置)
创建 src/views/Member/index.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 <script setup> </script> <template> <div class="container"> <div class="xtx-member-aside"> <div class="user-manage"> <h4>我的账户</h4> <div class="links"> <RouterLink to="/member/user">个人中心</RouterLink> </div> <h4>交易管理</h4> <div class="links"> <RouterLink to="/member/order">我的订单</RouterLink> </div> </div> </div> <div class="article"> <!-- 三级路由的挂载点 --> <RouterView /> </div> </div> </template> <style scoped lang="scss"> .container { display: flex; padding-top: 20px; .xtx-member-aside { width: 220px; margin-right: 20px; border-radius: 2px; background-color: #fff; .user-manage { background-color: #fff; h4 { font-size: 18px; font-weight: 400; padding: 20px 52px 5px; border-top: 1px solid #f6f6f6; } .links { padding: 0 52px 10px; } a { display: block; line-height: 1; padding: 15px 0; font-size: 14px; color: #666; position: relative; &:hover { color: $xtxColor; } &.active, &.router-link-exact-active { color: $xtxColor; &:before { display: block; } } &:before { content: ''; display: none; width: 6px; height: 6px; border-radius: 50%; position: absolute; top: 19px; left: -16px; background-color: $xtxColor; } } } } .article { width: 1000px; background-color: #fff; } } </style>
创建 src/views/Member/components/UserInfo.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 <script setup> const userStore = {} </script> <template> <div class="home-overview"> <!-- 用户信息 --> <div class="user-meta"> <div class="avatar"> <img :src="userStore.userInfo?.avatar" /> </div> <h4>{{ userStore.userInfo?.account }}</h4> </div> <div class="item"> <a href="javascript:;"> <span class="iconfont icon-hy"></span> <p>会员中心</p> </a> <a href="javascript:;"> <span class="iconfont icon-aq"></span> <p>安全设置</p> </a> <a href="javascript:;"> <span class="iconfont icon-dw"></span> <p>地址管理</p> </a> </div> </div> <div class="like-container"> <div class="home-panel"> <div class="header"> <h4 data-v-bcb266e0="">猜你喜欢</h4> </div> <div class="goods-list"> <!-- <GoodsItem v-for="good in likeList" :key="good.id" :good="good" /> --> </div> </div> </div> </template> <style scoped lang="scss"> .home-overview { height: 132px; background: url(@/assets/images/center-bg.png) no-repeat center / cover; display: flex; .user-meta { flex: 1; display: flex; align-items: center; .avatar { width: 85px; height: 85px; border-radius: 50%; overflow: hidden; margin-left: 60px; img { width: 100%; height: 100%; } } h4 { padding-left: 26px; font-size: 18px; font-weight: normal; color: white; } } .item { flex: 1; display: flex; align-items: center; justify-content: space-around; &:first-child { border-right: 1px solid #f4f4f4; } a { color: white; font-size: 16px; text-align: center; .iconfont { font-size: 32px; } p { line-height: 32px; } } } } .like-container { margin-top: 20px; border-radius: 4px; background-color: #fff; } .home-panel { background-color: #fff; padding: 0 20px; margin-top: 20px; height: 400px; .header { height: 66px; border-bottom: 1px solid #f5f5f5; padding: 18px 0; display: flex; justify-content: space-between; align-items: baseline; h4 { font-size: 22px; font-weight: 400; } } .goods-list { display: flex; justify-content: space-around; } } </style>
创建 src/views/Member/components/UserOrder.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 <script setup> // tab列表 const tabTypes = [ { name: "all", label: "全部订单" }, { name: "unpay", label: "待付款" }, { name: "deliver", label: "待发货" }, { name: "receive", label: "待收货" }, { name: "comment", label: "待评价" }, { name: "complete", label: "已完成" }, { name: "cancel", label: "已取消" } ] // 订单列表 const orderList = [] </script> <template> <div class="order-container"> <el-tabs> <!-- tab 切换 --> <el-tab-pane v-for="item in tabTypes" :key="item.name" :label="item.label" /> <div class="main-container"> <div class="holder-container" v-if="orderList.length === 0"> <el-empty description="暂无订单数据" /> </div> <div v-else> <!-- 订单列表 --> <div class="order-item" v-for="order in orderList" :key="order.id"> <div class="head"> <span>下单时间:{{ order.createTime }}</span> <span>订单编号:{{ order.id }}</span> <!-- 未付款,倒计时时间还有 --> <span class="down-time" v-if="order.orderState === 1"> <i class="iconfont icon-down-time"></i> <b>付款截止: {{ order.countdown }}</b> </span> </div> <div class="body"> <div class="column goods"> <ul> <li v-for="item in order.skus" :key="item.id"> <a class="image" href="javascript:;"> <img :src="item.image" alt="" /> </a> <div class="info"> <p class="name ellipsis-2"> {{ item.name }} </p> <p class="attr ellipsis"> <span>{{ item.attrsText }}</span> </p> </div> <div class="price">¥{{ item.realPay?.toFixed(2) }}</div> <div class="count">x{{ item.quantity }}</div> </li> </ul> </div> <div class="column state"> <p>{{ order.orderState }}</p> <p v-if="order.orderState === 3"> <a href="javascript:;" class="green">查看物流</a> </p> <p v-if="order.orderState === 4"> <a href="javascript:;" class="green">评价商品</a> </p> <p v-if="order.orderState === 5"> <a href="javascript:;" class="green">查看评价</a> </p> </div> <div class="column amount"> <p class="red">¥{{ order.payMoney?.toFixed(2) }}</p> <p>(含运费:¥{{ order.postFee?.toFixed(2) }})</p> <p>在线支付</p> </div> <div class="column action"> <el-button v-if="order.orderState === 1" type="primary" size="small"> 立即付款 </el-button> <el-button v-if="order.orderState === 3" type="primary" size="small"> 确认收货 </el-button> <p><a href="javascript:;">查看详情</a></p> <p v-if="[2, 3, 4, 5].includes(order.orderState)"> <a href="javascript:;">再次购买</a> </p> <p v-if="[4, 5].includes(order.orderState)"> <a href="javascript:;">申请售后</a> </p> <p v-if="order.orderState === 1"><a href="javascript:;">取消订单</a></p> </div> </div> </div> <!-- 分页 --> <div class="pagination-container"> <el-pagination background layout="prev, pager, next" /> </div> </div> </div> </el-tabs> </div> </template> <style scoped lang="scss"> .order-container { padding: 10px 20px; .pagination-container { display: flex; justify-content: center; } .main-container { min-height: 500px; .holder-container { min-height: 500px; display: flex; justify-content: center; align-items: center; } } } .order-item { margin-bottom: 20px; border: 1px solid #f5f5f5; .head { height: 50px; line-height: 50px; background: #f5f5f5; padding: 0 20px; overflow: hidden; span { margin-right: 20px; &.down-time { margin-right: 0; float: right; i { vertical-align: middle; margin-right: 3px; } b { vertical-align: middle; font-weight: normal; } } } .del { margin-right: 0; float: right; color: #999; } } .body { display: flex; align-items: stretch; .column { border-left: 1px solid #f5f5f5; text-align: center; padding: 20px; >p { padding-top: 10px; } &:first-child { border-left: none; } &.goods { flex: 1; padding: 0; align-self: center; ul { li { border-bottom: 1px solid #f5f5f5; padding: 10px; display: flex; &:last-child { border-bottom: none; } .image { width: 70px; height: 70px; border: 1px solid #f5f5f5; } .info { width: 220px; text-align: left; padding: 0 10px; p { margin-bottom: 5px; &.name { height: 38px; } &.attr { color: #999; font-size: 12px; span { margin-right: 5px; } } } } .price { width: 100px; } .count { width: 80px; } } } } &.state { width: 120px; .green { color: $xtxColor; } } &.amount { width: 200px; .red { color: $priceColor; } } &.action { width: 140px; a { display: block; &:hover { color: $xtxColor; } } } } } } </style>
在 router/index.js
中配置路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { path : 'member' , component : Member , children : [ { path : 'user' , component : UserInfo }, { path : 'order' , component : UserOrder } ] }
02. 会员中心-个人中心信息渲染
Note
个人中心部分直接使用 Pinia 中的数据
编辑 src/views/Member/components/UserInfo.vue
:
1 2 import { useUserStore } from '@/stores/userStore' ;const userStore = useUserStore ();
猜你喜欢部分走接口获取
编辑 src/apis/user.js
:
1 2 3 4 5 6 7 8 export const getLikeListAPI = ({ limit = 4 } ) => { return httpInstance ({ url : '/goods/relevant' , params : { limit } }); }
编辑 src/views/Member/components/UserInfo.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { useUserStore } from '@/stores/userStore' ;import { getLikeListAPI } from '@/apis/user' ;import { onMounted, ref } from 'vue' ;import GoodsItem from '@/views/Home/components/GoodsItem.vue' ;const userStore = useUserStore ();const likeList = ref ([]);const getLikeList = async ( ) => { const res = await getLikeListAPI ({ limit : 4 }); likeList.value = res.result ; };onMounted (() => { getLikeList (); });
1 2 3 4 5 6 7 8 9 10 11 12 ... <div class="like-container"> <div class="home-panel"> <div class="header"> <h4 data-v-bcb266e0="">猜你喜欢</h4> </div> <div class="goods-list"> <GoodsItem v-for="good in likeList" :key="good.id" :goods="good" /> </div> </div> </div> ...
03. 会员中心-我的订单-基础订单列表实现
Note
订单基础列表渲染
这里服务器好像断了……
创建 src/apis/order.js
:
1 2 3 4 5 6 7 8 9 import httpInstance from '@/utils/http' ;export const getUserOrder = (params ) => { return httpInstance ({ url : '/member/order' , method : 'GET' , params }) }
编辑 src/views/Member/components
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 <script setup> import { onMounted, ref } from 'vue'; import { getUserOrder } from '@/apis/order'; // tab列表 const tabTypes = [ { name: "all", label: "全部订单" }, { name: "unpay", label: "待付款" }, { name: "deliver", label: "待发货" }, { name: "receive", label: "待收货" }, { name: "comment", label: "待评价" }, { name: "complete", label: "已完成" }, { name: "cancel", label: "已取消" } ] // 订单列表 const orderList = ref([]); const params = ref({ orderState: 0, page: 1, pageSize: 2 }); const getOrderList = async () => { const res = await getUserOrder(params.value) console.log(res) total.value = res.result.counts } onMounted(() => getOrderList()); </script>
04. 会员中心-我的订单-tab 切换实现
Note
tab 切换实现
重点:切换 tab 时修改 orderState 参数 ,再次发起请求获取订单列表数据。
编辑 src/views/Member/components/UserOrder.vue
:
1 2 3 4 const tabChange = (type ) => { params.value .orderState = type; getOrderList (); }
1 <el-tabs @tab-change="tabChange">
05. 会员中心-我的订单-分页逻辑实现
Note
分页逻辑实现
使用列表数据生成分页(页数 = 总条数 / 每页条数)
切换分页修改 page 参数,再次获取订单列表数据
06.会员中心-细节优化
Note
默认三级路由设置
效果:当路由 path 为二级路由路径 member 的时候,右侧可以显示个人中心三级路由对应的组件
编辑 src/router/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ... { path : 'member' , component : Member , children : [ { path : '' , component : UserInfo }, { path : '' , component : UserOrder } ] } ...
编辑 src/views/Member/components/UserOrder.vue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 <script setup> import { getUserOrder } from '@/apis/order' import { onMounted, ref } from 'vue' // tab列表 const tabTypes = [ { name: "all", label: "全部订单" }, { name: "unpay", label: "待付款" }, { name: "deliver", label: "待发货" }, { name: "receive", label: "待收货" }, { name: "comment", label: "待评价" }, { name: "complete", label: "已完成" }, { name: "cancel", label: "已取消" } ] // 获取订单列表 const orderList = ref([]) const total = ref(0) const params = ref({ orderState: 0, page: 1, pageSize: 2 }) const getOrderList = async () => { const res = await getUserOrder(params.value) orderList.value = res.result.items total.value = res.result.counts } onMounted(() => getOrderList()) // tab切换 const tabChange = (type) => { console.log(type) params.value.orderState = type getOrderList() } // 页数切换 const pageChange = (page) => { console.log(page) params.value.page = page getOrderList() } const fomartPayState = (payState) => { const stateMap = { 1: '待付款', 2: '待发货', 3: '待收货', 4: '待评价', 5: '已完成', 6: '已取消' } return stateMap[payState] } </script> <template> <div class="order-container"> <el-tabs @tab-change="tabChange"> <!-- tab切换 --> <el-tab-pane v-for="item in tabTypes" :key="item.name" :label="item.label" /> <div class="main-container"> <div class="holder-container" v-if="orderList.length === 0"> <el-empty description="暂无订单数据" /> </div> <div v-else> <!-- 订单列表 --> <div class="order-item" v-for="order in orderList" :key="order.id"> <div class="head"> <span>下单时间:{{ order.createTime }}</span> <span>订单编号:{{ order.id }}</span> <!-- 未付款,倒计时时间还有 --> <span class="down-time" v-if="order.orderState === 1"> <i class="iconfont icon-down-time"></i> <b>付款截止: {{ order.countdown }}</b> </span> </div> <div class="body"> <div class="column goods"> <ul> <li v-for="item in order.skus" :key="item.id"> <a class="image" href="javascript:;"> <img :src="item.image" alt="" /> </a> <div class="info"> <p class="name ellipsis-2"> {{ item.name }} </p> <p class="attr ellipsis"> <span>{{ item.attrsText }}</span> </p> </div> <div class="price">¥{{ item.realPay?.toFixed(2) }}</div> <div class="count">x{{ item.quantity }}</div> </li> </ul> </div> <div class="column state"> <p>{{ fomartPayState(order.orderState) }}</p> <p v-if="order.orderState === 3"> <a href="javascript:;" class="green">查看物流</a> </p> <p v-if="order.orderState === 4"> <a href="javascript:;" class="green">评价商品</a> </p> <p v-if="order.orderState === 5"> <a href="javascript:;" class="green">查看评价</a> </p> </div> <div class="column amount"> <p class="red">¥{{ order.payMoney?.toFixed(2) }}</p> <p>(含运费:¥{{ order.postFee?.toFixed(2) }})</p> <p>在线支付</p> </div> <div class="column action"> <el-button v-if="order.orderState === 1" type="primary" size="small"> 立即付款 </el-button> <el-button v-if="order.orderState === 3" type="primary" size="small"> 确认收货 </el-button> <p><a href="javascript:;">查看详情</a></p> <p v-if="[2, 3, 4, 5].includes(order.orderState)"> <a href="javascript:;">再次购买</a> </p> <p v-if="[4, 5].includes(order.orderState)"> <a href="javascript:;">申请售后</a> </p> <p v-if="order.orderState === 1"><a href="javascript:;">取消订单</a></p> </div> </div> </div> <!-- 分页 --> <div class="pagination-container"> <el-pagination :total="total" @current-change="pageChange" :page-size="params.pageSize" background layout="prev, pager, next" /> </div> </div> </div> </el-tabs> </div> </template>
07. 拓展课-SKU 组件-功能拆解