Web-Vue3-小兔鲜儿

学习自黑马程序员。

资源

正文

day01 Vue3 入门

02. 认识 Vue3

用 Vue2 写一个点击按钮自增:

vue
<template>
    <button @click="addCount">{{ count }}</button>
</template>
 
<script>
export default {
    data() {
        return {
            count: 0
        }
    },
    methods: {
        addCount() {
            this.count++
        }
    }
}
</script>

用 Vue3 写一个点击按钮自增:

vue
<template>
    <button @click="addCount">{{ count }}</button>
</template>
 
<script setup>
    import { ref } from 'vue';
    const count = ref(0);
    const addCount = () => { count.value++; };
</script>
 

Vue3 与 Vue2 相比:代码量变少了,分散式维护转为集中式维护

注意

Vue3

  • 更容易维护
    • 组合式 API
    • 更好的 TypeScript 支持
  • 更快的速度
    • 重写 diff 算法
    • 模版编译优化
    • 更高效的组件初始化
  • 更小的体积
    • 良好的 TreeShaking
    • 按需引入
  • 更优的数据响应式
    • Proxy

03. 使用 create-vue 创建项目

Vue3 使用了新的脚手架工具:

create-vue 是 Vue 官方新的脚手架工具,底层切换到了 vite(下一代前端工具链),为开发提供极速响应。

webp

执行命令:

shell
npm init vue@latest
webp

安装之:

shell
cd vue3-project
npm install

运行之:

shell
npm run dev
webp

04. 熟悉项目目录和关键文件

关键文件:

  1. vite.config.js - 项目的配置文件 基于 vite 的配置
  2. package.json - 项目包文件 核心依赖项变成了 Vue3.xvite
  3. main.js-入口文件 createApp 函数创建应用实例
  4. app.vue - 根组件 SFC 单文件组件 script-template-style
    • 变化一:脚本 script 和模板 template 顺序调整
    • 变化二:模板 template 不再要求唯一根元素
    • 变化三:脚本 script 添加 setup 标识支持组合式 API
  5. index.html - 单页入口 提供 id 为 app 的挂载点

05. 组合式 API 入口-setup

如此编写代码:

vue
<script setup>
	const message = 'this is a message';
	const logMessage = () => {
		console.log(message);
	}
</script>
 
<template>
	{{ message }}
	<button @click="logMessage">Log Message</button>
</template>
webp

使用 setup 这一语法糖能够大大减少代码量:

webp

注意

  • setup 选项的执行时机?

    • beforeCreate 钩子之前 自动执行

      webp
  • setup 写代码的特点是什么

    • 定义数据+函数 然后以对象方式 return
  • <script setup> 解决了什么问题?

    • 经过语法糖的封装更简单的使用组合式 API
  • setup 中的 this 还指向组件实例吗?指向 undefined

06. 组合式 API-reactive 和 ref 函数

注意

reactive()

作用:接受对象类型数据的参数传入并返回一个响应式的对象

核心步骤:

vue
<script setup>
	// 导入
    import { reactive } from 'vue';
    // 执行函数 传入函数 变量接收
    const state = reactive(对象类型数据);
</script>
  1. 从 vue 包中导入 reactive 函数
  2. <script setup>执行 reactive 函数并传入类型为对象的初始值,并使用变量接收返回值
vue
<script setup>
	import { reactive } from 'vue';
	const state = reactive({
		count: 0
	});
 
	const setCount = () => {
		state.count++;
	}
</script>
 
<template>
	<button @click="setCount()">{{ state.count }}</button>
</template>

注意

ref()

作用:接收简单类型或者对象类型的数据传入并返回一个响应式的对象

核心步骤:

vue
<script setup>
	// 导入
    import { ref } from 'vue';
    // 执行函数 传入函数 变量接收
    const count = ref(对象类型数据);
</script>
  1. 从 vue 包中导入 ref 函数
  2. <script setup>执行 ref 函数并传入初始值,使用变量接收 ref 函数的返回值
vue
<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

注意

computed 计算属性函数

计算属性基本思想和 Vue2 的完全一致,组合式 API 下的计算属性只是修改了写法

核心步骤:

  1. 导入 computed 函数
  2. 执行函数 在回调参数中 return 基于响应式数据做计算的值,用变量接收
vue
<script setup>
	// 导入
    import { computed } from 'vue';
    // 执行函数 变量接受 在回调参数中 return 计算值
    const computedState = computed(() => {
		return 基于响应式数据做计算之后的值;
    })
</script>
vue
<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, 3, 4, 5, 6, 7, 8, 9, 10 ]
计算响应式数组 - [ 3, 4, 5, 6, 7, 8, 9, 10 ]

08. 组合式 API-watch-基本使用和立即执行

注意

watch 函数

作用:侦听一个或者多个数据的变化,数据变化时执行回调函数

俩个额外参数:

  1. immediate(立即执行)
  2. deep(深度侦听)

基础使用-侦听单个数据

  1. 导入 watch 函数
  2. 执行 watch 函数传入要侦听的响应式数据**(ref 对象)**和回调函数
vue
<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>

注意

基础使用-侦听多个数据

说明:同时侦听多个响应式数据的变化,不管哪个数据变化都需要执行回调

vue
<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>

注意

immediate

说明:在侦听器创建时立即触发回调,响应式数据变化之后继续执行回调

vue
<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>
count 值改变了,新值:0, 旧值:undefined

09. 组合式 API-watch-深度侦听和精确侦听

注意

deep

**默认机制:**通过 watch 监听的 ref 对象默认是浅层侦听的,直接修改嵌套的对象属性不会触发回调执行,需要开启 deep 选项

vue
<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>
webp

注意

精确侦听对象的某个属性

需求:在不开启 deep 的前提下,侦听 age 的变化,只有 age 变化时才执行回调

vue
<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>

注意

  1. 作为 watch 函数的第一个参数,ref 对象需要添加 .value 吗?

    不需要,watch 会自动读取

  2. watch 只能侦听单个数据吗?

    单个或者多个

  3. 不开启 deep,直接修改嵌套属性能触发回调吗?

    不能,默认是浅层侦听

  4. 不开启 deep,想在某个层次比较深的属性变化时执行回调怎么做?

    可以把第一个参数写成函数的写法,返回要监听的具体属性

10. 组合式 API-生命周期函数

注意

Vue3 的生命周期 API(选项式 VS 组合式)

选项式 API组合式 API功能
beforeCreate / createdsetup组件实例初始化阶段的钩子函数,beforeCreate 在组件实例创建之前触发,created 在实例创建后触发。可以在这里初始化数据或调用 API。
beforeMountonBeforeMount在虚拟 DOM 初次渲染完成并插入到页面之前触发,适合做一些即将渲染的 DOM 操作。
mountedonMounted在组件挂载到页面之后触发,常用于操作已经渲染的 DOM 或者发起网络请求。
beforeUpdateonBeforeUpdate在响应式数据更新并即将触发 DOM 更新前调用,可以在这里访问旧的 DOM 状态。
updatedonUpdated在组件数据更新并重新渲染 DOM 后触发,可用于获取更新后的 DOM 状态。
beforeUnmountonBeforeUnmount在组件实例即将销毁之前调用,可用于清理事件监听器或定时器等资源。
unmountedonUnmounted在组件实例销毁之后调用,可用于进一步清理或日志记录。

生命周期函数基本使用

  1. 导入生命周期函数
  2. 执行生命周期函数 传入回调

执行多次

生命周期函数是可以执行多次的,多次执行时传入的回调会在时机成熟时依次执行

vue
<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>
vue
mounted 1
mounted 2
mounted 3
  1. 组合式 API 中生命周期函数的格式是什么?

    on + 生命周期名字

  2. 组合式 API 中可以使用 onCreated 吗?

    没有这个钩子函数,直接写到 setup 中

  3. 组合式 API 中组件卸载完毕时执行哪个函数?

    onUnmounted

11. 组合式 API 下的父子通信-父传子

注意

组合式 API 下的父传子

基本思想

  1. 父组件中给子组件绑定属性

    vue
    <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>
  2. 子组件内部通过 props 选项接收

    vue
    <script setup>
    const props = defineProps({
        message: String,
        count: Number
    })
    </script>
                                                    
    <template>
        <div class="son">
            <h3>子组件</h3>
            <div>
                父组件传入的数据 - {{ message }} - 父组件传入的计数 - {{ count }}
            </div>
        </div>
    </template>
父组件 App
子组件
父组件传入的数据 - 来自父组件的消息 - 父组件传入的计数 - 100

12. 组合式 API 下的父子通信-子传父

注意

组合式 API 下的子传父

基本思想

  1. 父组件中给子组件标签通过@绑定事件

    vue
    <script setup>
    	import SonCom from './components/son-com.vue';
    	const getMessage = (msg) => {
    		console.log(msg);
    	}
    </script>
     
    <template>
    	<sonCom @get-message="getMessage"/>
    </template>
  2. 子组件内部通过 $emit 方法触发事件

    vue
    <script setup>
    const emit = defineEmits(['get-message']);
    const sendMsg = () => {
        emit('get-message', 'hello');
    }
    </script>
                                                    
    <template>
        <button @click="sendMsg">sendMsg</button>
    </template>

注意

父传子

  1. 父传子的过程中通过什么方式接收props?

    defineProps({属性名:类型})

  2. setup 语法糖中如何使用父组件传过来的数据?

    const props=defineProps({属性名: 类型})

子传父

  1. 子传父的过程中通过什么方式得到 emit 方法?

    defineEmits(['事件名称'])

13. 组合式 API-模板引用

注意

模板引用的概念

通过 ref 标识获取真实的 dom 对象或者组件实例对象

如何使用(以获取dom为例 组件同理)

  1. 调用 ref 函数生成一个 ref 对象
  2. 通过 ref 标识绑定 ref 对象到标签
vue
<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 编译宏指定哪些属性和方法允许访问

注意

  1. 获取模板引用的时机是什么?

    组件挂载完毕

  2. defineExpose 编译宏的作用是什么?

    显式暴露组件内部的属性和方法

14. 组合式 API-provide 和 inject

注意

作用和场景

顶层组件向任意的底层组件传递数据和方法,实现跨层组件通信

webp

跨层传递普通数据

  1. 顶层组件通过 provide 函数提供数据

    vue
    <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>
  2. 中层组件啥也不做

    vue
    <script setup>
        import RoomMsgComment from './room-msg-comment.vue';
    </script>
     
    <template>
        <div>中层组件<RoomMsgComment /></div>
    </template>
  3. 底层组件通过 inject 函数获取数据

    vue
    <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>
顶层组件
中层组件
底层组件
来自顶层组件中的数据为:this is room data
来自顶层组件中的响应式数据:0

15-16. Vue3 综合小案例

注意

项目地址 git clone http://git.itcast.cn/heimaqianduan/vue3-basic-project.git

  1. 模版已经配置好了案例必须的安装包
  2. 案例用到的接口在 README.MD文件 中
  3. 案例项目有俩个分支,main 主分支为开发分支,complete 分支为完成版分支供开发完参考
vue
<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>
vue
<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 项目

注意

什么是 Pinia

Pinia 是 Vue 的专属的最新状态管理库,是 Vuex 状态管理工具的替代品

  1. 提供更加简单的 API(去掉了 mutation)
  2. 提供符合组合式风格的 API(和 Vue3 新语法统一)
  3. 去掉了 modules 的概念,每一个 store 都是一个独立的模块
  4. 搭配 TypeScript 一起使用提供可靠的类型推断

根据 开始 | Pinia 里的教程给一个 vue 项目添加 pinia。

shell
npm install pinia

创建一个 pinia 实例 (根 store) 并将其传递给应用(修改 main.js):

javascript
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):

javascript
import { defineStore } from 'pinia'
 
export const useCounterStore = defineStore('counter', {
    state: () => {
        return { count: 0 }
    },
    actions: {
        increment() {
            this.count++
        }
    }
})

App.vue 中使用 store:

vue
<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

javascript
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 获得的列表:

vue
<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>
webp

04. Pinia-storeToRefs 和调试

  • 使用 storeToRefs 对变量进行解构赋值。

  • 使用方法包裹对函数进行解构赋值。

vue
<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 管理

webp

以此法创建一个 vue-rabbit 项目。

调整 src 目录:

  • src
    • apis:API 接口文件夹
    • assets
    • components
    • composables:组合函数文件夹
    • directives:全局指令文件夹
    • router
    • stores
    • styles:全局样式文件夹
    • utils:工具函数文件夹
    • views

接着使用 git 管理项目

基于 create-vue 创建出来的项目默认没有初始化 git 仓库,需要我们手动初始化

执行命令并完成首次提交

  1. git init
  2. git add .
  3. git commit -m “init"

06. 项目起步-别名路径联想设置

注意

什么是别名路径联想提示

在编写代码的过程中,一旦 输入 @/,VSCode 会立刻 联想出 src 下的所有子目录和文件,统一文件路径访问不容易出错。

如何进行配置

  1. 在项目的根目录下新增 jsconfig.json 文件

  2. 添加 json 格式的配置项,如下:

    json
    {
      "compilerOptions": {
        "paths": {
          "@/*": ["./src/*"]
        }
      }
    }

配置完成:

webp

提交修改:

shell
git add .
git commit -m "完成别名联想设置"

07. 项目起步-elementPlus 自动按需导入配置

注意

小兔鲜项目的组件分类

小兔鲜项目 UI 组件

  • 通用型组件(ElementPlus):Dialog 模态框
  • 业务定制化组件(手写):商品热榜组件

添加 ElementPlus 到项目(按需导入)

  • 看文档 快速开始 | Element Plus

    • 安装

      shell
      npm install element-plus --save
    • 配置按需引入

      shell
      npm install -D unplugin-vue-components unplugin-auto-import

      配置 vite.config.js

      javascript
      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'
       
      // https://vite.dev/config/
      export default defineConfig({
        plugins: [
          vue(),
          vueDevTools(),
              AutoImport({
            resolvers: [ElementPlusResolver()],
          }),
          Components({
            resolvers: [ElementPlusResolver()],
          }),
        ],
        resolve: {
          alias: {
            '@': fileURLToPath(new URL('./src', import.meta.url))
          },
        },
      })
    • 测试组件

      webp

08. 项目起步-elementPlus 主题色定制

注意

为什么需要主题定制

小免鲜主题色和 elementPlus 默认的主题色存在冲突,通过定制主题让 elementPlus 的主题色和小免鲜项目保持一致

webp

如何定制(scss 变量替换方案)

  • 安装 scss:

    shell
    npm i sass -D
  • 准备定制样式文件:

    styles/element/index.scss

    scss
    @forward 'element-plus/theme-chalk/src/common/var.scss' with (
        $colors: (
            'primary': (
                'base': #27ba9b,
            ),
            'success': (
                'base': #1dc779,
            ),
            'warning': (
                'base': #ffb302,
            ),
            'danger': (
                'base': #e26237,
            ),
            'error': (
                'base': #cf4444,
            )
        )
    );
  • 对 ElementPlus 样式进行覆盖

    通知 Element 采用 scss 语言 -> 导入定制 scss 文件覆盖

    javascript
    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'
                                
    // https://vite.dev/config/
    export default defineConfig({
      plugins: [
        vue(),
        vueDevTools(),
            AutoImport({
          resolvers: [ElementPlusResolver()],
        }),
        Components({
          resolvers: [ElementPlusResolver({ importStyle: 'sass' })],
        }),
      ],
      resolve: {
        alias: {
          '@': fileURLToPath(new URL('./src', import.meta.url))
        },
      },
      css: {
        preprocessorOptions: {
          scss: {
            additionalData: `@use "@/styles/element/index.scss" as *;`
          }
        }
      }
    })
webp

09. 项目起步-axios 基础配置

注意

axios 基础配置

  1. 安装 axios

    shell
    npm i axios
  2. 配置基础实例(统一接口配置)

    webp

    utils/http.js 中创建 axios 实例:

    javascript
    import axios from 'axios';
                                              
    // 创建 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:

    javascript
    import httpInstance from '@/utils/http';
                                              
    export function getCategory() {
        return httpInstance({
            url: 'home/category/head'
        })
    }

    main.js 中测试接口函数:

    javascript
    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() 方法可以执行多次,每次执行就会生成一个新的实例,比如:

javascript
const http1 = axios.create({ baseURL: 'url1'})
const http2 = axios.create({ baseURL: 'url2'})

10. 项目起步-项目整体路由设计

注意

设计首页和登录页的路由(一级路由)

路由设计原则:找内容切换的区域,如果是页面整体切换,则为一级路由

创建:

App.vue

vue
<script setup>
 
</script>
 
<template>
    <RouterView/>
</template>
  • views

    • Category

      • index.vue

        vue
        <template>
            <div>我是 Category 页</div>
        </template>
    • Home

      • index.vue

        vue
        <template>
            <div>我是 Home 页</div>
        </template>
    • Layout

      • index.vue

        vue
        <template>
            <div>我是首页</div>
            <RouterView/>
        </template>
    • Login

      • index.vue

        vue
        <template>
            <div>我是登录页</div>
        </template>

配置路由 router/index.js

javascript
// createRouter: 创建 router 实例对象
// createWebHistory: 创建 history 模式的路由
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/

我是首页
我是 Home 页

http://localhost:5173/login

我是登录页

http://localhost:5173/category

我是首页
我是 Category 页

11. 项目起步-静态资源引入和 ErrorLen 安装

注意

图片资源和样式资源

资源说明

  1. 实际工作中的图片资源通常由 UI 设计师 提供,常见的图片格式有 png, svg 等都是由 UI 切图交给前端
  2. 样式资源通常是指项目初始化的时候进行样式重置,常见的比如开源的 normalize.css 或者手写

资源操作

  1. 图片资源-把 images 文件夹放到 assets 目录下

  2. 样式资源-把 common.scss 文件放到 styles 目录下

    然后在 main.js 中引入:

    javascript
    import '@/styles/common.scss'

然后推荐安装 error lens 插件。

13. Layout-静态模版结构搭建

配置好:

  • views

    • Layout

      • index.vue

        vue
        <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

          vue
          <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

          vue
          <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

          vue
          <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>
webp

14. Layout-字体图标引入

注意

如何引入

阿里的字体图标库支持多种引入方式,小免鲜项目里采用的是 font-class 引用的方式,index.html 下:

html
<!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-一级导航渲染

注意

功能描述

使用后端接口渲染渲染一级路由导航

实现步骤

  1. 根据接口文档封装接口函数
  2. 发送请求获取数据列表
  3. v-for 渲染页面

编辑 apis/layout.js

javascript
import httpInstance from '@/utils/http';
 
export function getCategoryAPI() {
    return httpInstance({
        url: 'home/category/head'
    })
}

使用 v-for 将接收到的数据渲染到 LayoutHeader.vue

vue
<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 部分的代码:

javascript
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')
webp

16. Layout-吸顶导航交互实现

注意

吸顶交互

要求:浏览器在上下滚动的过程中,如果距离顶部的滚动距离大于 78px,吸顶导航显示,小于 78px 隐藏

  • 准备吸顶导航组件 components/LayoutFixed.vue

    vue
    <script setup>
      
    </script>
      
    <template>
      <div class="app-header-sticky">
        <div class="container">
          <RouterLink class="logo" to="/" />
          <!-- 导航区域 -->
          <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>
            <li>
              <RouterLink to="/">母婴</RouterLink>
            </li>
            <li>
              <RouterLink to="/">个护</RouterLink>
            </li>
            <li>
              <RouterLink to="/">严选</RouterLink>
            </li>
            <li>
              <RouterLink to="/">数码</RouterLink>
            </li>
            <li>
              <RouterLink to="/">运动</RouterLink>
            </li>
            <li>
              <RouterLink to="/">杂项</RouterLink>
            </li>
          </ul>
      
          <div class="right">
            <RouterLink to="/">品牌</RouterLink>
            <RouterLink to="/">专题</RouterLink>
          </div>
        </div>
      </div>
    </template>
     
    <style scoped lang='scss'>
    .app-header-sticky {
      width: 100%;
      height: 80px;
      position: fixed;
      left: 0;
      top: 0;
      z-index: 999;
      background-color: #fff;
      border-bottom: 1px solid #e4e4e4;
      // 此处为关键样式!!!
      // 状态一:往上平移自身高度 + 完全透明
      transform: translateY(-100%);
      opacity: 0;
     
      // 状态二:移除平移 + 完全不透明
      &.show {
        transition: all 0.3s linear;
        transform: none;
        opacity: 1;
      }
        
      .container {
        display: flex;
        align-items: center;
      }
        
      .logo {
        width: 200px;
        height: 80px;
        background: url("@/assets/images/logo.png") no-repeat right 2px;
        background-size: 160px auto;
      }
        
      .right {
        width: 220px;
        display: flex;
        text-align: center;
        padding-left: 40px;
        border-left: 2px solid $xtxColor;
        
        a {
          width: 38px;
          margin-right: 40px;
          font-size: 16px;
          line-height: 1;
        
          &:hover {
            color: $xtxColor;
          }
        }
      }
    }
     
    .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;
        }
      }
    }
    </style>
  • 获取滚动数据:

    从 vueuse 中获取相关函数,安装这个库:

    shell
    npm i @vueuse/core

    LayoutFixed.vue 中实现这个逻辑:

    vue
    <script setup>
        import { useScroll } from '@vueuse/core';
        const { y } = useScroll(window);
    </script>
                              
    <template>
      <div class="app-header-sticky" :class="{ show: y > 78 }">
      ...

17. Layout-Pinia 优化重复请求

注意

webp

从 pinia 中获取数据,分发给各个组件。

创建 stores/category.js

javascript
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() 获取数据:

vue
<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>

{% tabs Layout_017 %}

vue
<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>
vue
<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>

{% endtabs %}

day03

01. Home-整体结构拆分和分类实现

分析主页结构:

webp

创建如下文件:

  • Home

    • index.vue

      vue
      <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

        vue
        <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 中的数据渲染

    webp

分析 Pinia 中获得的数据,设计 HomeCategory.vue

vue
<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>
webp

02. Home-banner 轮播图实现

轮播图实现

  • 准备模板

  • 熟悉 elementPlus 相关组件

    根据 Carousel 走马灯 | Element Plus 写出走马灯的代码(HomeBanner.vue):

    vue
    <script setup>
     
    </script>
     
    <template>
      <div class="home-banner">
        <el-carousel height="500px">
          <el-carousel-item v-for="item in 4" :key="item">
            <img src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-04-15/6d202d8e-bb47-4f92-9523-f32ab65754f4.jpg" 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>
  • 获取接口数据

    apis/home.js 中获取 API:

    javascript
    import httpInstance from '@utils/http';
     
    export function getCategoryAPI() {
        return httpInstance({
            url: 'home/banner'
        })
    }
  • 渲染组件

    vue
    <script setup>
    import { getBannerAPI } from '@/apis/home';
    import { ref, onMounted } from 'vue';
     
    const bannerList = ref([]);
     
    const getBanner = async () => {
        const res = await getBannerAPI();
        console.log("res", res);
        bannerList.value = res.result;
        console.log("bannerList.value", bannerList.value);
    }
     
    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>

03. Home-面板组件封装

注意

场景说明

问:组件封装解决了什么问题?

答:1. 复用问题 2. 业务维护问题

新鲜好物和人气推荐模块,在结构上非常相似,只是内容不同,通过组件封装可以实现复用结构的效果

webp

组件封装

核心思路:把可复用的结构只写一次,把可能发生变化的部分抽象成组件参数(props/插槽)

实现步骤

  1. 不做任何抽象,准备静态模版

    创建一个 Home/components/HomePanel.vue

    vue
    <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>
  2. 抽象可变的部分

    • 主标题和副标题是纯文本,可以抽象成 prop 传入
    • 主体内容是复杂的模版,抽象成插槽传入
    vue
    <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):

vue
<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>
webp

04. Home-新鲜好物业务实现

准备模板(HomePanel 组件)

HomeNew.vue

vue
<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">&yen;{{ 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

javascript
import httpInstance from '@/utils/http';
 
export function getBannerAPI() {
    return httpInstance({
        url: 'home/banner'
    })
}
 
/**
 * @description: 获取新鲜好物
 * @param {*}
 * @return {*}
 */
export const findNewAPI = () => {
    return httpInstance({
        url: 'home/new'
    });
}

使用这个 API:

vue
<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">&yen;{{ item.price }}</p>
                </RouterLink>
            </li>
        </ul>
    </HomePanel>
</template>
webp

05. Home-图片懒加载指令实现

注意

场景和指令用法

场景:电商网站的首页通常会很长,用户不一定能访问到页面靠下面的图片,这类图片通过懒加载优化手段可以做到只有进入视口区域才发送图片请求

指令用法:<img v-img-lazy="item.picture"/>

在图片 img 身上绑定指令,该图片只有在正式进入到视口区域时才会发送图片网络请求。

实现思路和步骤

核心原理:图片进入视口才发送资源请求

  1. 熟悉指令语法

    自定义指令 | Vue.js

    main.js 中注册这个指令:

    javascript
    // 定义全局指令
    app.directive('img-lazy', {
        mounted(el, binding) {
            console.log(el, binding)
        }
    })

    apis/home.js 中设置 API:

    javascript
    export const getHotAPI = () => {
        return httpInstance({
            url: 'home/hot'
        });
    }

    Home/components/HomeHot.vue 中使用 v-img-lazy 这个自定义指令:

    vue
    <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>
  2. 判断图片是否进入视口(vueUse

  3. 测试图片监控是否生效

  4. 如果图片进入视口,发送图片资源请(img.src=url

    javascript
    app.directive('img-lazy', {
        mounted(el, binding) {
            console.log(el, binding);
            // el: 指令所在元素
            // binding: 指令绑定的数据对象
            useIntersectionObserver(el, ([{ isIntersecting }]) => {
                if (isIntersecting) {
                    // 处理元素进入视口的逻辑
                    // 例如:加载图片
                    el.src = binding.value;
                }
            });
        }
    });
  5. 测试图片资源是否发出

webp

06. Home-懒加载指令优化

注意

逻辑书写位置不合理

问:懒加载指令的逻辑直接写到入口文件中,合理吗?

答:不合理,入口文件通常只做一些初始化的事情,不应该包含太多的逻辑代码,可以通过插件的方法把懒加载指令封装为插件main.js 入口文件只需要负责注册插件即可


创建 src/directives/index.js

javascript
import { useIntersectionObserver } from "@vueuse/core";
 
export const lazyPlugin = {
    install(app) {
        app.directive('img-lazy', {
            mounted(el, binding) {
                console.log(el, binding);
                // el: 指令所在元素
                // binding: 指令绑定的数据对象
                useIntersectionObserver(el, ([{ isIntersecting }]) => {
                    if (isIntersecting) {
                        // 处理元素进入视口的逻辑
                        // 例如:加载图片
                        el.src = binding.value;
                    }
                });
            }
        });
    }
}

main.js 中使用这个插件:

javascript
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 产品列表实现

注意

Product 产品列表

Product 产品列表是一个常规的列表渲染,实现步骤如下:

  1. 熟悉并准备静态模板
  2. 封装接口
  3. 获取数据渲染模板
  4. 图片懒加载

src/apis/home.js

javascript
export const getGoodsAPI = () => {
    return httpInstance({
        url: 'home/goods'
    });
}

Home/components/HomeProduct.vue

vue
<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">&yen;{{ 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 组件封装

注意

为什么要封装 Goodsltem 组件

在小兔鲜项目的很多个业务模块中都需要用到同样的商品展示模块,没必要重复定义,封装起来,方便复用

如何封装

核心思想:把要显示的数据对象设计为 props 参数,传入什么数据对象就显示什么数据

GoodsItem.vue

vue
<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">&yen;{{ 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

vue
<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

javascript
// createRouter: 创建 router 实例对象
// createWebHistory: 创建 history 模式的路由
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.vueLayoutFixed.vue

vue
<RouterLink :to="`/category/${item.id}`">{{ item.name }}</RouterLink>

10. 一级分类-面包屑导航渲染

注意

面包屑导航渲染

  1. 准备组件模板

    views/Category/index.vue

    vue
    <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>
  2. 封装接口函数

    src/apis/category.js

    javascript
    import httpInstance from '@/utils/http';
     
    export function getCategoryAPI(id) {
        return httpInstance({
            url: '/category',
            params: {
                id
            }
        });
    }
  3. 调用接口获取数据(使用路由参数)

  4. 渲染模板

    views/Category/index.vue

    vue
    <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. 一级分类-轮播图功能实现

注意

分类轮播图实现

分类轮播图和首页轮播图的区别只有一个,接口参数不同,其余逻辑完成一致

请求参数

参数名位置类型必填说明
distributionSitequerystring示例值:1
说明:广告区域展示位置(投放位置,1 为首页,2 为分类商品页)默认是 1

改造先前的接口(适配参数)

src/apis/home.js

javascript
export function getBannerAPI(params = {}) {
    const { distribution = '1'} = params;
    return httpInstance({
        url: `home/banner`,
        params: {
            distribution
        }
    });
}

迁移首页轮播图逻辑

views/Category/index.vue

vue
<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.vueLayoutFixed.vue

vue
<RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</RouterLink>

分类列表渲染

分类的数据已经在面包屑导航实现的时候获取到了,只需要通过 v-for 遍历出来即可

准备分类模板:

v-for 遍历已有数据

views/Category/index.vue

vue
<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. 一级分类-解决路由缓存问题

注意

  1. 路由缓存问题产生的原因是什么?

    路由只有参数变化时,会复用组件实例

    使用带有参数的路由时需要注意的是,当用户从 /users/johnny 导航到 /users/jolyne 时,相同的组件实例将被重复使用。因为两个路由都渲染同个组件,比起销毁再创建,复用则显得更加高效。不过,这也意味着组件的生命周期钩子不会被调用

  2. 俩种方案都可以解决路由缓存问题,如何选择呢?

    • 在意性能问题,选择 onBeforeUpdate,精细化控制:

      views/Category/index.vue

      javascript
      const getCategory = async (id=route.params.id) => {
          const res = await getCategoryAPI(id);
          categoryData.value = res.result;
      }
       
      onMounted(() => {
          getCategory();
          getBanner();
      })
       
      onBeforeRouteUpdate((to) => {
          getCategory(to.params.id);
      });
    • 不在意性能问题,选择 key,简单粗暴。

      vue
      <RouterView :key="$route.fullPath"/>

14. 一级分类-使用逻辑函数拆分业务

注意

概念理解

基于逻辑函数拆分业务是指把同一个组件中独立的业务代码通过函数做封装处理,提升代码的可维护性

webp

具体怎么做

实现步骤:

  1. 按照业务声明以 use 打头的逻辑函数
  2. 独立的业务逻辑封装到各个函数内部
  3. 函数内部把组件中需要用到的数据或者方法 return 出去
  4. 组件中调用函数把数据或者方法组合回来使用

创建 views/Category/composables/useBanner.jsviews/Category/composables/useCategory.js

{% tabs Category_014 %}

vue
<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>
javascript
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
    }
}
javascript
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
    }
}

{% endtabs %}

day04

01. 二级分类-整体认识和路由配置

创建路由组件

创建 src/views/SubCategory/index.vue

vue
<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

javascript
// createRouter: 创建 router 实例对象
// createWebHistory: 创建 history 模式的路由
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(分类商品下):

vue
<RouterLink :to="`/category/sub/${i.id}`">
webp

02. 二级分类-面包屑导航实现

封装接口

src/apis/category.js 里加一个 getCategoryFilterAPI

javascript
export const getCategoryFilterAPI = (id) => {
    return httpInstance({
        url: '/category/sub/filter',
        params: {
            id
        }
    });
}

调用接口渲染模板

修改 SubCategory/index.vue

vue
<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

vue
<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

javascript
export const getSubCategoryAPI = (data) => {
    return httpInstance({
        url: '/category/goods/temporary',
        method: 'POST',
        data
    });
}

添加额外参数实现筛选功能

修改 SubCategory/index.vue

javascript
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());

商品列表中添加:

vue
<GoodsItem v-for="goods in goodsList" :goods="goods" :key="goods.id"></GoodsItem>

04. 二级分类-列表筛选功能实现

注意

核心逻辑:点击 tab,切换筛选条件参数 sortField,重新发送列表请求

获取激活项数据

根据 组件 | Element 中的用法,设置 SubCategory/index.vue 中的 el-tabs 组件:

vue
<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 的回调函数的实现:

java
// tab 切换回调
const tabChange = () => {
    console.log('tab 切换了', reqData.value.sortField);
    reqData.value.page = 1;
    getGoodsList();
}

05. 二级分类-列表无限加载实现

无限加载功能实现

webp

核心实现逻辑:使用 elementPlus 提供的 v-infinite-scroll 指令监听是否满足触底条件,满足加载条件时让页数参数加一获取下一页数据,做新老数据拼接渲染

配置 v-infinite-scroll

vue
<div class="body" v-infinite-scroll="load">
    <!-- 商品列表-->
    <GoodsItem v-for="goods in goodsList" :goods="goods" :key="goods.id"></GoodsItem>
</div>

页数加一获取下一页数据

老数据和新数据拼接

加载完毕结束监听

javascript
// 加载更多
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

javascript
const router = createRouter({
	...
    scrollBehavior() {
      return { top: 0 }
    }
})

07. 详情页-整体认识和路由配置

创建详情组件

创建 src/views/Detail/index.vue

vue
<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

javascript
// createRouter: 创建 router 实例对象
// createWebHistory: 创建 history 模式的路由
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

vue
<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">&yen;{{ item.price }}</p>
                </RouterLink>
            </li>
        </ul>
    </HomePanel>
</template>

08. 详情页-基础数据渲染

封装接口

编辑 src/apis/details.js

javascript
import httpInstance from '@/utils/http';
 
export const getDetail = (id) => {
  return httpInstance({
    url: `/goods`,
    params: {
      id
    }
  })
}

调用获取数据

渲染模板

修改 src/Detail/index.vue

vue
<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>

注意

渲染模版时遇到对象的多层属性(如 good.details.pictures)访问可能出现什么问题?

TypeError: Cannot read properties of undefined (reading 'properties')

09. 详情页-热榜区-基础组件封装和数据渲染

注意

webp

结论:两块热榜相比,结构一致,标题 title 和列表内容不同

封装 Hot 热榜组件

src/apis/details.js 中封装接口:

javascript
export const getHotGoodsAPI = ({id, type, limit=3}) => {
    return httpInstance({
        url: '/goods/hot',
        params: {
            id,
            type,
            limit
        }
    });
}

创建 src/views/Details/components/DetailHot.vue

vue
<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">&yen;{{ 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 接收参数:

vue
<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">&yen;{{ item.price }}</p>
    </RouterLink>
  </div>
</template>

views/Detail/index.vue 中传递参数:

vue
<DetailHot :hot-type="1"/>
<DetailHot :hot-type="2"/>

11. 详情页-图片预览组件-小图切换大图显示

注意

webp

思路:维护一个数组图片列表,鼠标划入小图记录当前小图下标值,通过下标值在数组中取对应图片,显示到大图位置。

创建 src/components/ImageView/index.vue

vue
<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 中放置这个组件:

javascript
import ImageView from '@/components/ImageView/index.vue'
vue
<!-- 图片预览区 -->
<ImageView/>

12. 详情页-图片预览组件-放大镜-滑块跟随移动

注意

webp

思路: 获取到当前的鼠标在盒子内的相对位置(useMouseInElement),控制滑块跟随鼠标移动(left/top)

  1. 有效移动范围内的计算逻辑

    横向:100 < elementX < 300,left = elementX - 小滑块宽度一半

    纵向:100 < elementY < 300,top = elementy - 小滑块高度一半

  2. 边界距离控制

    横向:elementX > 300 left = 200 elementX < 100 left = 0

    纵向:elementY > 300 top = 200 elementY < 100 top = 0

vue
<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. 详情页-图片预览组件-放大镜-大图效果实现

注意

放大镜效果实现-大图效果实现

webp

效果:为实现放大效果,大图的宽高是小图的俩倍

思路:大图的移动方向和滑块移动方向相反,且数值为 2 倍

放大镜效果实现 - 鼠标移入控制显隐

思路:鼠标移入盒子(isOutside),滑块和大图才显示(v-show)

编辑 src/components/ImageView/index.vue

vue
<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,让 imageListprops 参数的形式存在:

javascript
defineProps({
    imageList: {
        type: Array,
        default: () => []
    }
});

src/views/Detail/index.vue 中传参:

vue
<ImageView :image-list="goods.mainPictures"/>

15.详情页-SKU 组件熟悉使用

注意

SKU 的概念

存货单位(英语:stock keeping unit,SKU/,cs,kelju:/),也翻译为库存单元,是一个会计学名词,定义为库存管理中的最小可用单元,例如纺织品中一个 SKU 通常表示规格、颜色、款式,而在连锁零售门店中有时称单品为一个 SKU。

webp

SKU 组件的作用:产出当前用户选择的商品规格,为加入购物车操作提供数据信息。

SKU 组件使用

问:在实际工作中,经常会遇到别人写好的组件,熟悉一个三方组件,首先重点看什么?

答:propsemitprops 决定了当前组件接收什么数据,emit 决定了会产出什么数据

验证组件是否成功使用:

传入必要数据是否交互功能正常 → 点击选择规格,是否正常产出数据

导入 src/components/XtxSku,在 src/views/Detail/index.vue 中使用这个插件:

vue
<!-- sku组件 -->
<XtxSku :goods="goods"/>

16. 详情页-通用组件统一注册全局

注意

为什么要优化

背景:components 目录下有可能还会有很多其他通用型组件,有可能在多个业务模块中共享,所有统一进行全局组件注册比较好

components 插件(把 components 目录下的所有组件进行全局注册)→ main.js(注册插件)

创建 components/index.js

javascript
// 把 components 中的所有组件都进行全局化注册
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 中引入:

javascript
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

vue
<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 &copy; 小兔鲜儿</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

vue
<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. 登录-表单校验实现

注意

为什么需要校验

作用:前端提前校验可以省去一些错误的请求提交,为后端节省接口压力。

表单数据 → 前端校验(过滤错误请求)→后端查询是否匹配

表单如何进行校验

ElementPlus 表单组件内置了表单校验功能,只需要按照组件要求配置必要参数即可(直接看文档)

思想:当功能很复杂时,通过多个组件各自负责某个小功能,再组合成一个大功能是组件设计中的常用方法

表单校验步骤

  1. 按照接口字段准备表单对象并绑定
  2. 按照产品要求准备规则对象并绑定
  3. 指定表单域的校验字段名
  4. 把表单对象进行双向绑定

用户名:不能为空,字段名为 account

密码:不能为空且为 6-14 个字符,字段名为 password

同意协议:必选,字段名为 agree

修改 src/views/Login/index.vue

vue
<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 &copy; 小兔鲜儿</p>
            </div>
        </footer>
    </div>
</template>

03. 登录-表单校验-自定义校验规则

注意

自定义校验规则

ElementPlus 表单组件内置了初始的校验配置,应付简单的校验只需要通过配置即可,如果想要定制一些特殊的校验需求,可以使用自定义校验规则,格式如下:

javascript
{
    validator:(rule, val.,callback) => {
        // 自定义校验逻辑
        // value: 当前输入的数据
        // callback:校验处理函数 校验通过调用
    }
}

校验逻辑:如果勾选了协议框,通过校验,如果没有勾选,不通过校验。

vue
<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 &copy; 小兔鲜儿</p>
            </div>
        </footer>
    </div>
</template>

04. 登录-表单校验-统一校验

注意

整个表单的内容验证

思考:每个表单域都有自己的校验触发事件,如果用户一上来就点击登录怎么办呢?

答:在点击登录时需要对所有需要校验的表单进行统一校验

javascript
formEl.validate((valid) => {
    if (valid) {
        console.log('submit!')
    } else {
        console.log('error submit!')
        return false
    }
})

获取 form 组件实例 → 调用实例方法

vue
<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. 登录-基础功能实现

注意

登录业务流程

  • 表单校验通过

  • 封装登录接口

    创建 src/apis/user.js

    javascript
    import httpInstance from '@/utils/http';
     
    export const loginAPI = ({ account, password }) => {
        return httpInstance({
            url: '/login',
            method: 'POST',
            data: {
                account,
                password
            }
        });
    }
  • 调用登录接口

    • 登录成功后续逻辑处理(提示用户,跳转首页)

      src/views/Login/index.vue 下:

      vue
      <script setup>
      ...
      const formRef = ref(null);
      const doLogin = () => {
          const { account, password } = form.value;
          formRef.value.validate(async (valid) => {
              if (valid) {
                  const res = await loginAPI({ account, password });
                  console.log(res);
                  ElMessage({ type: 'success', message: '登录成功' });
                  router.replace({ path: '/' });
              }
          })
      }
      </script>
    • 登录失败的业务逻辑(抛出错误提示)

      src/utils/http.js 下,编辑相应拦截器:

      javascript
      // 响应拦截器:在接收到响应后统一处理结果,常用于错误处理或数据格式转换。
      http.interceptors.response.use(res => res.data, e => {
          ElMessage({
              type: "warning",
              message: e.response.data.message
          });
          return Promise.reject(e);
      });
      webp

06. 登录-Pinia 管理用户数据

注意

为什么要用 Pinia 管理数据

由于用户数据的特殊性,在很多组件中都有可能进行共享,共享的数据使用 Pinia 管理会更加方便

如何使用 Pinia 管理数据

遵循理念:和数据相关的所有操作(state+action)都放到 Pinia 中,组件只负责触发 action 函数

componentPinia (state + action)

创建 src/stores/user.js

javascript
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 管理数据:

javascript
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 用户数据持久化

注意

持久化用户数据说明

  1. 用户数据中有一个关键的数据叫做 Token(用来标识当前用户是否登录),而 Token 持续一段时间才会过期
  2. Pinia 的存储是基于内存的,刷新就丢失,为了保持登录状态就要做到刷新不丢失,需要配合持久化进行存储

目的:保持 token 不丢失,保持登录状态

最终效果:操作 state 时会自动把用户数据在本地的 localStorage 也存一份,刷新的时候会从 localstorage 中先取

安装 pinia 中用于用户数据持久化的插件:

shell
npm i pinia-plugin-persistedstate

src/main.js 中使用这个插件:

javascript
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):

javascript
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
});

注意

关键步骤总结和插件运行机制

安装插件包pinia 注册插件需要持久化的 store 进行配置

运行机制: 在设置 state 的时候会自动把数据同步给 localstorage,在获取 state 数据的时候会优先从 localStorage 中取

08. 登录-登录和非登录状态下的模版适配

编辑 src/views/Layout/components/LayoutNav.vue

vue
<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

注意

为什么要在请求拦截器携带 Token

Token 作为用户标识,在很多个接口中都需要携带 Token 才可以正确获取数据,所以需要在接口调用时携带 Token。另外,为了统一控制采取请求拦截器携带的方案。

如何配置

Axios 请求拦截器可以在接口正式发起之前对请求参数做一些事情,通常 Token 数据会被注入到请求 header 中,格式按照后端要求的格式进行拼接处理

编辑 src/utils/http.js 中的请求拦截器:

javascript
import axios from 'axios';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user';
 
// 创建 axios 实例
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;

如此做:

webp

10. 登录-退出登录功能实现

注意

退出登录业务实现

  1. 点击退出登录弹确认框
  2. 点击确定按钮实现退出登录逻辑
  3. 登录逻辑包括:
    1. 清除当前用户信息
    2. 跳转到登录页面

src/stores/user.js 中设置清楚用户信息的逻辑:

javascript
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

vue
<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 拦截处理

注意

业务背景

Token 的有效性可以保持一定时间,如果用户一段时间不做任何操作,Token 就会失效,使用失效的 Token 再去请求一些接口,接口就会报 401 状态码错误,需要我们做额外处理。

两个需要思考的问题

  1. 我们能确定用户到底是在访问哪个接口时出现的 401 错误吗?在什么位置去拦截这个 401?
  2. 检测到 401 之后又该干什么呢?

解决方案-在 axios 响应拦截器做统一处理

失败回调中拦截 401清除掉过期的用户信息,跳转到登录页

编辑 src/utils/http.js

javascript
// 响应拦截器:在接收到响应后统一处理结果,常用于错误处理或数据格式转换。
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. 购物车-流程梳理和本地加入购物车实现

注意

购物车业务逻辑梳理拆解

webp
  1. 整个购物车的实现分为俩个大分支,本地购物车操作和接口购物车操作
  2. 由于购物车数据的特殊性,采取 Pinia 管理购物车列表数据并添加持久化缓存

本地购物车 - 加入购物车实现

webp

创建 src/stores/cartStore.js

javascript
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

vue
<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>
webp

13. 购物车-本地-头部购物车列表渲染

14. 购物车-本地-头部购物车删除功能实现

15. 购物车-本地-头部购物车统计计算

注意

本地购物车 - 头部购物车列表渲染

准备头部购物车组件从 Pinia 中获取数据渲染列表

本地购物车 - 头部购物车统计计算

用什么来实现:计算属性

计算逻辑是什么:

  1. 商品总数计算逻辑:商品列表中的所有商品 count 累加之和
  2. 商品总价钱计算逻辑:商品列表中的所有商品的 count * price 累加之和

创建 src/views/Layout/components/HeaderCart.vue

vue
<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">&yen;{{ 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>&yen; {{ 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 中渲染头部购物车:

vue
<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 中编写删除购物车的逻辑:

javascript
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

vue
<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>&yen;{{ i.price }}</p>
                            </td>
                            <td class="tc">
                                <el-input-number v-model="i.count" />
                            </td>
                            <td class="tc">
                                <p class="f16 red">&yen;{{ (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 中配置路由:

javascript
...
{
    path: 'cartlist',
    component: CartList
}
...

02. 购物车-本地-列表购物车单选功能

注意

列表购物车-单选功能

核心思路:单选的核心思路就是始终把单选框的状态和 Pinia 中 store 对应的状态保持同步

注意事项:v-model 双向绑定指令不方便进行命令式的操作(因为后续还需要调用接口),所以把 v-model 回退到一般模式,也就是:model-value@change 的配合实现

webp

编辑 src/views/CartList/index.vue

vue
<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) 这种写法可以传递额外的参数。

测试:

webp

03. 购物车-本地-购物车列表全选功能

注意

列表购物车-全选

核心思路:

  1. 操作单选决定全选:只有当 cartList 中的所有项都为 true 时,全选状态才为 true
  2. 操作全选决定单选:cartList 中的所有项的 selected 都要跟着一起变
webp

编辑 src/views/CartList/index.vue

javascript
const allCheck = (selected) => {
    store.allCheck(selected);
}
vue
<th width="120">
    <el-checkbox :model-value="store.isAll" @change="allCheck"/>
</th>

编辑 src/stores/cartStore.js

javascript
const isAll = computed(() => cartList.value.every((item) => item.selected));
 
const allCheck = (selected) => {
    cartList.value.forEach((item) => item.selected = selected);
}
 
return {
    ...
    allCheck,
    isAll
}

04. 购物车-本地-购物车列表统计数据实现

注意

列表购物车-统计数据实现

stores/cartStore/js 中定义计算逻辑:

  1. 已选择数量 = cartList 中所有 selected 字段为 true 项的 count 之和

    javascript
    const selectedCount = computed(() => cartList.value.filter((item) => item.selected).reduce((acc, cur) => acc + cur.count, 0));
  2. 商品合计 = cartlist 中所有 selected 字段为 true 项的 count * price 之和

    javascript
    const selectedPrice = computed(() => cartList.value.filter((item) => item.selected).reduce((acc, cur) => acc + cur.price * cur.count, 0));

src/views/CartList/index.vue 中编辑操作栏:

vue
<!-- 操作栏 -->
<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. 购物车-接口-加入购物车

注意

接口购物车-整体业务流程回顾

webp

结论

到目前为止,购物车在非登录状态下的各种操作都已经 ok 了,包括 action 的封装、触发、参数传递,剩下的事情就是在 action 中做登录状态的分支判断,补充登录状态下的接口操作逻辑即可

接口购物车-加入购物车

webp

创建 src/apis/cart.js

javascript
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,如果登陆时,调用后端接口:

javascript
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. 购物车-接口-删除购物车

注意

接口购物车-删除购物车

webp

src/apis/cart.js 中封装删除购物车的 API:

javascript
export const delCartAPI = (ids) => {
    return httpInstance({
        url: '/member/cart',
        method: 'DELETE',
        data: {
            ids
        }
    })
}

src/stores/cartStore.js 中编写逻辑:

javascript
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. 退出登录-清空购物车数据

注意

业务需求

在用户退出登录时,除了清除用户信息之外,也需要把购物车数据清空

webp

cartStroe.js 里写清空购物车的逻辑:

javascript
const clearCart = () => {
    cartList.value = [];
}

userStore.js 中在退出时调用清除购物车的 action:

javascript
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. 购物车-合并本地购物车到服务器

注意

合并购物车业务实现

问:用户在非登录时进行的所有购物车操作,我们的服务器能知道吗?

答:不能!不能的话不是白操作了吗?还本地购物车的意义在哪?

解决办法:在用户登录时,把本地的购物车数据和服务端购物车数据进行合并操作

webp

apis/cart.js 封装合并购物车的 API

javascript
export const mergeCartAPI = (data) => {
    return httpInstance({
        url: '/member/cart/merge',
        method: 'POST',
        data
    })
}

stores/userStore.js 中调用这个 API:

javascript
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 页面:

javascript
{
    path: 'checkout',
    component: Checkout
}

src/views/CarList/index.vue 中的下单结算中设置路由跳转:

vue
<div class="total">
    <el-button size="large" type="primary" @click="$router.push('/checkout')">下单结算</el-button>
</div>

src/views/apis/checkout.js 中封装 API:

javascript
import httpInstance from '@/utils/http';
 
export const getCheckInfoAPI = () => {
    return httpInstance({
        url: '/member/order/pre',
    })
}

创建 src/views/Checkout/index.vue

vue
<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>&yen;{{ i.price }}</td>
                                <td>{{ i.count }}</td>
                                <td>&yen;{{ i.totalPrice }}</td>
                                <td>&yen;{{ 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. 结算-地址切换-打开弹框交互实现

注意

地址切换交互需求分析

  1. 打开弹框交互:点击切换地址按钮,打开弹框,回显用户可选地址列表
  2. 切换地址交互:点击切换地址,点击确定按钮,激活地址替换默认收货地址

打开弹框交互实现

webp

编辑 src/views/Checkout/index.vue

vue
<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. 结算-切换地址-地址激活交互实现

注意

地址激活交互实现

原理:地址切换是我们经常遇到的 tab 切换类 需求,这类需求的实现逻辑都是相似的

  1. 点击时记录一个当前激活地址对象 activeAddress,点击哪个地址就把哪个地址对象记录下来
  2. 通过动态类名:class 控制激活样式类型 active 是否存在,判断条件为:激活地址对象的 id === 当前项 id

编辑 src/views/Checkout/index.vue

vue
<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>&yen;{{ i.price }}</td>
                                <td>{{ i.count }}</td>
                                <td>&yen;{{ i.totalPrice }}</td>
                                <td>&yen;{{ 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. 结算-生成订单功能实现

注意

业务需求说明

确定结算信息没有问题之后,点击提交订单按钮,需要做以下俩个事情:

  1. 调用接口生成订单 id,并且携带 id 跳转到支付页
  2. 调用更新购物车列表接口,更新购物车状态
webp

准备支付页路由

编辑 src/router/index.js

javascript
{
    path: 'pay',
    component: Pay
}

创建 src/views/Pay/index.vue

vue
<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

javascript
export const createOrderAPI = (data) => {
    return httpInstance({
        url: '/member/order',
        method: 'POST',
        data
    })
}

点击按钮调用接口,得到订单 id,携带 id 完成路由跳转

编辑 src/views/Checkout/index.vue

javascript
// 创建订单
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. 支付-渲染基础数据

注意

渲染基础数据

支付页有俩个关键数据,一个是要支付的钱数,一个是倒计时数据(超时不支付商品释放)

webp

封装获取订单详情的接口

创建 src/apis/pay.js

javascript
import httpInstance from '@/utils/http';
 
export const getOrderAPI = (id) => {
    return httpInstance({
        url: `/member/order/${id}`
    })
}

获取关键数据并渲染

编辑 src/views/Checkout/index.vue

vue
<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>&yen;{{ i.price }}</td>
                                <td>{{ i.count }}</td>
                                <td>&yen;{{ i.totalPrice }}</td>
                                <td>&yen;{{ 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. 支付-实现支付功能

注意

支付业务流程

webp

作为前端,只需要处理框选中的逻辑。

编辑 src/views/Pay/index.vue

javascript
// 支付地址
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}`

支付宝沙箱账号信息:

账号jfjbwb4477@sandbox.com
登录密码111111
支付密码111111

但是现在好像付款不了了……囧

15. 支付-支付结果展示

注意

业务需求理解

webp

配置 paycallback 路由

编辑 src/router/index.js

javascript
{
    path: 'paycallback',
    component: PayBack
}

根据支付结果适配支付状态

创建并编辑 src/views/PayBack/index.vue

vue
<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. 支付-封装倒计时函数

安装日期格式化库:

shell
npm install dayjs

创建并编辑 src/composables/useCountDown.js

javascript
// 封装倒计时逻辑函数
import { computed, onUnmounted, ref } from 'vue'
import dayjs from 'dayjs'
export const useCountDown = () => {
    // 1. 响应式的数据
    let timer = null
    const time = ref(0)
    // 格式化时间 为 xx分xx秒
    const formatTime = computed(() => dayjs.unix(time.value).format('mm分ss秒'))
    // 2. 开启倒计时的函数
    const start = (currentTime) => {
        // 开始倒计时的逻辑
        // 核心逻辑的编写:每隔1s就减一
        time.value = currentTime
        timer = setInterval(() => {
            time.value--
        }, 1000)
    }
    // 组件销毁时清除定时器
    onUnmounted(() => {
        timer && clearInterval(timer)
    })
    return {
        formatTime,
        start
    }
}

更新 src/views/Pay/index.vue

vue
<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. 会员中心-整体功能梳理和路由配置

注意

整体功能梳理

  1. 个人中心 - 个人信息和猜你喜欢数据渲染
  2. 我的订单 - 各种状态下的订单列表展示

路由配置(包括三级路由配置)

webp

创建 src/views/Member/index.vue

vue
<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

vue
<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

vue
<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 中配置路由:

javascript
{
    path: 'member',
    component: Member,
    children: [
        {
            path: 'user',
            component: UserInfo
        },
        {
            path: 'order',
            component: UserOrder
        }
    ]
}

02. 会员中心-个人中心信息渲染

注意

webp

个人中心部分直接使用 Pinia 中的数据

编辑 src/views/Member/components/UserInfo.vue

javascript
import { useUserStore } from '@/stores/userStore';
const userStore = useUserStore();

猜你喜欢部分走接口获取

编辑 src/apis/user.js

javascript
export const getLikeListAPI = ({ limit = 4}) => {
    return httpInstance({
        url: '/goods/relevant',
        params: {
            limit
        }
    });
}

编辑 src/views/Member/components/UserInfo.vue

javascript
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();
});
vue
...
<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. 会员中心-我的订单-基础订单列表实现

注意

订单基础列表渲染

webp

这里服务器好像断了……

创建 src/apis/order.js

javascript
import httpInstance from '@/utils/http';
 
export const getUserOrder = (params) => {
    return httpInstance({
        url: '/member/order',
        method: 'GET',
        params
    })
}

编辑 src/views/Member/components

vue
<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 切换实现

注意

tab 切换实现

重点:切换 tab 时修改 orderState 参数,再次发起请求获取订单列表数据。

webp

编辑 src/views/Member/components/UserOrder.vue

javascript
const tabChange = (type) => {
    params.value.orderState = type;
    getOrderList();
}
vue
<el-tabs @tab-change="tabChange">

05. 会员中心-我的订单-分页逻辑实现

注意

分页逻辑实现

  1. 使用列表数据生成分页(页数 = 总条数 / 每页条数)

    webp
  2. 切换分页修改 page 参数,再次获取订单列表数据

    webp

06.会员中心-细节优化

注意

默认三级路由设置

效果:当路由 path 为二级路由路径 member 的时候,右侧可以显示个人中心三级路由对应的组件

编辑 src/router/index.js

javascript
...
{
    path: 'member',
    component: Member,
    children: [
        {
            path: '',
            component: UserInfo
        },
        {
            path: '',
            component: UserOrder
        }
    ]
}
...

注意

订单状态显示适配

编辑 src/views/Member/components/UserOrder.vue

vue
<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 组件-功能拆解