Vue电商项目实战-4
登录页
- 路由配置与静态模板导入
- 导入静态模板
- 配置路由
- 在LayoutNav中实现点击按钮进入登陆页面
<script setup>
</script>
<template>
<div>
<header class="login-header">
<div class="container m-top-20">
<h1 class="logo">
<RouterLink to="/">小兔鲜</RouterLink>
</h1>
<RouterLink class="entry" to="/">
进入网站首页
<i class="iconfont icon-angle-right"></i>
<i class="iconfont icon-angle-right"></i>
</RouterLink>
</div>
</header>
<section class="login-section">
<div class="wrapper">
<nav>
<a href="javascript:;">账户登录</a>
</nav>
<div class="account-box">
<div class="form">
<el-form label-position="right" label-width="60px"
status-icon>
<el-form-item label="账户">
<el-input/>
</el-form-item>
<el-form-item label="密码">
<el-input/>
</el-form-item>
<el-form-item label-width="22px">
<el-checkbox size="large">
我已同意隐私条款和服务条款
</el-checkbox>
</el-form-item>
<el-button size="large" class="subBtn">点击登录</el-button>
</el-form>
</div>
</div>
</div>
</section>
<footer class="login-footer">
<div class="container">
<p>
<a href="javascript:;">关于我们</a>
<a href="javascript:;">帮助中心</a>
<a href="javascript:;">售后服务</a>
<a href="javascript:;">配送与验收</a>
<a href="javascript:;">商务合作</a>
<a href="javascript:;">搜索推荐</a>
<a href="javascript:;">友情链接</a>
</p>
<p>CopyRight © 小兔鲜儿</p>
</div>
</footer>
</div>
</template>
<style scoped lang='scss'>
.login-header {
background: #fff;
border-bottom: 1px solid #e4e4e4;
.container {
display: flex;
align-items: flex-end;
justify-content: space-between;
}
.logo {
width: 200px;
a {
display: block;
height: 132px;
width: 100%;
text-indent: -9999px;
background: url("@/assets/images/logo.png") no-repeat center 18px / contain;
}
}
.sub {
flex: 1;
font-size: 24px;
font-weight: normal;
margin-bottom: 38px;
margin-left: 20px;
color: #666;
}
.entry {
width: 120px;
margin-bottom: 38px;
font-size: 16px;
i {
font-size: 14px;
color: $xtxColor;
letter-spacing: -5px;
}
}
}
.login-section {
background: url('@/assets/images/login-bg.png') no-repeat center / cover;
height: 488px;
position: relative;
.wrapper {
width: 380px;
background: #fff;
position: absolute;
left: 50%;
top: 54px;
transform: translate3d(100px, 0, 0);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.15);
nav {
font-size: 14px;
height: 55px;
margin-bottom: 20px;
border-bottom: 1px solid #f5f5f5;
display: flex;
padding: 0 40px;
text-align: right;
align-items: center;
a {
flex: 1;
line-height: 1;
display: inline-block;
font-size: 18px;
position: relative;
text-align: center;
}
}
}
}
.login-footer {
padding: 30px 0 50px;
background: #fff;
p {
text-align: center;
color: #999;
padding-top: 20px;
a {
line-height: 1;
padding: 0 10px;
color: #999;
display: inline-block;
~a {
border-left: 1px solid #ccc;
}
}
}
}
.account-box {
.toggle {
padding: 15px 40px;
text-align: right;
a {
color: $xtxColor;
i {
font-size: 14px;
}
}
}
.form {
padding: 0 20px 20px 20px;
&-item {
margin-bottom: 28px;
.input {
position: relative;
height: 36px;
>i {
width: 34px;
height: 34px;
background: #cfcdcd;
color: #fff;
position: absolute;
left: 1px;
top: 1px;
text-align: center;
line-height: 34px;
font-size: 18px;
}
input {
padding-left: 44px;
border: 1px solid #cfcdcd;
height: 36px;
line-height: 36px;
width: 100%;
&.error {
border-color: $priceColor;
}
&.active,
&:focus {
border-color: $xtxColor;
}
}
.code {
position: absolute;
right: 1px;
top: 1px;
text-align: center;
line-height: 34px;
font-size: 14px;
background: #f5f5f5;
color: #666;
width: 90px;
height: 34px;
cursor: pointer;
}
}
>.error {
position: absolute;
font-size: 12px;
line-height: 28px;
color: $priceColor;
i {
font-size: 14px;
margin-right: 2px;
}
}
}
.agree {
a {
color: #069;
}
}
.btn {
display: block;
width: 100%;
height: 40px;
color: #fff;
text-align: center;
line-height: 40px;
background: $xtxColor;
&.disabled {
background: #cfcdcd;
}
}
}
.action {
padding: 20px 40px;
display: flex;
justify-content: space-between;
align-items: center;
.url {
a {
color: #999;
margin-left: 10px;
}
}
}
}
.subBtn {
background: $xtxColor;
width: 100%;
color: #fff;
}
</style>
{
path:'/login',
component:login
},
<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>
- 表单检验功能
- ElementPlus内置了表单检验功能,只需要按照组件要求配置必要参数即可:
- el-form:绑定表单对象和规则对象
- el-form-item:绑定使用的规则手段
- el-input:双向绑定表单数据
- 表单校验步骤:
- 按照接口字段准备表单对象并绑定
- 按照产品要求准备规则对象并绑定
- 指定表单域的校验字段名
- 把表单对象进行双向绑定
- ElementPlus内置了表单检验功能,只需要按照组件要求配置必要参数即可:
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,max:14,message:'密码长度为6-14个字符',trigger:'blur' }
]
}
<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"/>
</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>
- 自定义表单校验
- 若想要定制一些特殊的校验规则,可以使用自定义规则
- 校验逻辑:勾选了协议框,通过校验,否则不通过校验
import { ref } from 'vue';
//准备表单对象
const form = ref({
account:'',
password:'',
agree:true
})
//准备规则对象
const rules = {
account:[
{ required:true,message:'用户名不能为空',trigger:'blur' }
],
password:[
{ required:true,message:'密码不能为空',trigger:'blur' },
{ min:6,max:14,message:'密码长度为6-14个字符',trigger:'blur' }
],
agree:[
{
validator:(rule,value,callback)=>{
console.log(value)
if(value){
callback()
}else{
callback(new Error('请勾选协议!'))
}
}
}
]
}
<el-form-item prop="agree" label-width="22px">
<el-checkbox v-model="form.agree" size="large" >
我已同意隐私条款和服务条款
</el-checkbox>
</el-form-item>
- 统一校验
- 在点击登录时需要对所有表单进行统一校验
- 获取form组件实例
- 调用实例方法
//获取form实例做统一校验
const formRef = ref(null)
const doLogin =()=>{
formRef.value.validate((valid)=>{
console.log(valid)
if(valid){
//Login
}
})
}
<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"/>
</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>
- 基础功能实现
- 封装登录接口
- 调用登录接口
- 登录成功后逻辑处理:提示用户,跳转首页
- 登录失败的逻辑业务:抛出错误提醒
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/el-message.css'
import { useRouter } from 'vue-router';
//获取form实例做统一校验
const formRef = ref(null)
const router = useRouter()
const doLogin =()=>{
const { account,password } = form.value
formRef.value.validate(async(valid)=>{
console.log(valid)
if(valid){
//Login
const res =await loginAPI({ account,password })
console.log(res)
ElMessage({ type:'success',message:'登录成功'})
router.replace({path:'/'})
}
})
}
import request from '@/utils/https'
export const loginAPI =({ account,password}) => {
return request({
url:'/login',
method:'POST',
data:{
account,
password
}
})
}
// axios响应式拦截器
httpInstance.interceptors.response.use(res => res.data, e => {
ElMessage({
type:'warning',
message:e.response.data.message
})
return Promise.reject(e)
})
- Pinia管理用户数据
- 和数据相关的所有操作(action+state)都放在Pinia里面
- 组件触发action
- action函数内部接口获取数据
- 用户信息存入
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
}
})
await useStore.getUserInfo({ account,password })
- Pinia数据用户持久化
- 持久化用户数据说明:
- 用户数据中有一个关键数据Token,用于标识用户是否登录,过一段时间才会过期
- Pinia的存储是基于内存的,刷新就会丢失,为保证登录后不消失,需要配合持久化存储
- 目的:保持token不丢失,保持登录状态
- 操作state时会自动把用户数据在本地localStorage也保存一份,刷新时从localStorage先取
- npm i pinia-plugin-persistedstate
- 在main.js中引入pinia-plugin-persistedstate,并注册持久化插件
- 在user.js中创建store时,把persistent设置为true
- 持久化用户数据说明:
import '@/styles/common.scss'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
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)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.use(lazyPlugin)
app.use(componentPlugin)
app.mount('#app')
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,
})
- 登录和非登录状态下的状态切换
- 多模板适配的通用思路:有几个需要适配的模板就写几个templa段,用条件渲染v-if控制显示即可
<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>
<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>
- 请求拦截器携带token
- Token作为用户标识,很多个接口需要通过token才能获取数据,所有需要在接口调用时携带token,为了统一控制采用响应拦截器的方案
- 从pinia获取token数据
- 按照后端的要求拼接token数据
- Token作为用户标识,很多个接口需要通过token才能获取数据,所有需要在接口调用时携带token,为了统一控制采用响应拦截器的方案
import { useUserStore } from "@/stores/user";
// axios请求拦截器
httpInstance.interceptors.request.use(config => {
const userStore = useUserStore()
const token = userStore.userInfo.token
if(token){
config.headers.Authorization = `Bearer ${token}`
}
return config
}, e => Promise.reject(e))
- 退出登录业务实现
- 点击退出登录弹确认框
- 点击确认按钮实现退出登录逻辑
- 清理当前用户信息:在user.js中写入clearUserInfo实现清除信息,在LayoutNav中使用
- 跳转至登录页面:使用useRouter实现
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router';
const userStore = useUserStore()
const router = useRouter()
const confirm = () => {
console.log('用户要退出登录')
userStore.clearUserInfo()
router.push('/login')
}
<li>
<el-popconfirm @confirm="confirm" title="确认退出吗?" confirm-button-text="确认" cancel-button-text="取消">
<template #reference>
<a href="javascript:;">退出登录</a>
</template>
</el-popconfirm>
</li>
- Token失效401拦截处理
- 业务背景:用户如果一段时间不做任何操作,Token就会失效,失效的token再去请求接口就会报401错误
- 在axios响应拦截器中做统一处理
- 失败回调拦截401
- 清除掉过期的用户信息
- 跳转至登录页
- 失败回调拦截401
购物车页
- 本地加入购物车实现
- 封装cartStore
- 组件点击添加按钮
- 选择了规格:调用action添加
- 添加过:count+1
- 未添加:push出来
- 未选择规格:提示用户选择规格
- 选择了规格:调用action添加
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useCartStore = defineStore('cart', () => {
//1.定义state - cartList
const cartList = ref([])
//2.定义action - addCart
const addCart = async (goods) => {
// 添加购物车操作
// 已添加过 - count + 1
// 没有添加过 - 直接push
// 思路:通过匹配传递过来的商品对象中的skuId能不能在cartList中找到,找到了就是添加过
const item = cartList.value.find((item) => goods.skuId === item.skuId)
if (item) {
// 找到了
item.count++
} else {
// 没找到
cartList.value.push(goods)
}
}
return {
cartList,
addCart
}
}, {
persist: true
})
import { useCartStore } from '@/stores/cartStore.js'
const cartStore = useCartStore()
let skuObj = {}
const Skuchange = (sku) => {
console.log(sku)
skuObj =sku
}
const count = ref(1)
const countChange = (count)=>{
console.log(count)
}
const addCart = () => {
if (skuObj.skuId) {
//规格已经选择 触发action
cartStore.addCart({
id: goods.value.id,
name: goods.value.name,
picture: goods.value.mainPictures[0],
price: goods.value.price,
count: count.value,
skuId: skuObj.skuId,
attrsText: skuObj.specsText,
selected: true
})
} else {
//规格没有选择 提示用户
ElMessage.warning('请选择规格')
}
}
<!-- 数据组件 -->
<el-input-number v-model="count" @change="countChange" />
<!-- 按钮组件 -->
<div>
<el-button size="large" class="btn" @click="addCart">
加入购物车
</el-button>
</div>
- 头部购物车列表渲染
- 准备头部购物车组件
- 从Pinia中获取数据渲染列表,获取列表长度并渲染
<script setup>
import { useCartStore } from '@/stores/cartStore'
const cartStore = useCartStore()
</script>
<template>
<div class="cart">
<a class="curr" href="javascript:;">
<i class="iconfont icon-cart"></i><em>{{ cartStore.cartList.length }}</em>
</a>
<div class="layer">
<div class="list">
<div class="item" v-for="i in cartStore.cartList" :key="i">
<RouterLink to="">
<img :src="i.picture" alt="" />
<div class="center">
<p class="name ellipsis-2">
{{ i.name }}
</p>
<p class="attr ellipsis">{{ i.attrsText }}</p>
</div>
<div class="right">
<p class="price">¥{{ i.price }}</p>
<p class="count">x{{ i.count }}</p>
</div>
</RouterLink>
<i class="iconfont icon-close-new" @click="store.delCart(i.skuId)"></i>
</div>
</div>
<div class="foot">
<div class="total">
<p>共 10 件商品</p>
<p>¥ 100.00 </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>
<!-- 头部购物车 -->
<HeaderCart />
- 头部购物车删除功能实现
- 编写删除的action函数,实现删除逻辑:
- 找到要删除项的下标值—splice
- 使用数组的过滤方法—filter
- 组件中调用action并传递skuId
- 编写删除的action函数,实现删除逻辑:
const delCart = (skuId) =>{
const idx = cartList.value.findIndex((item)=>skuId === item.skuId)
cartList.value.splice(idx,1)
}
return {
cartList,
addCart,
delCart
}
<i class="iconfont icon-close-new" @click="cartStore.delCart(i.skuId)"></i>
- 头部购物车统计计算
- 用计算属性实现,在cartStore写相应的action函数
- 商品总数计算逻辑:商品列表中所有的count相加
- 商品价格计算逻辑:商品列表中所有的count*price相加
//计算属性
const allCount = computed(()=> cartList.value.reduce((a,c)=>a+c.count,0))
const allPrice = computed(()=> cartList.value.reduce((a,c)=>a+c.count*c.price,0))
return {
cartList,
addCart,
delCart,
allCount,
allPrice
}
<div class="foot">
<div class="total">
<p>共 {{ cartStore.allCount }} 件商品</p>
<p>¥ {{ cartStore.allPrice }} </p>
</div>
<el-button size="large" type="primary" >去购物车结算</el-button>
</div>
</div>
- 列表购物车基础数据渲染
- 准备静态模板
- 配置路由:cartlist
- 用click实现路由跳转
- 运用useCartStore实现列表渲染
<script setup>
import { useCartStore } from '@/stores/cartStore'
const cartStore = useCartStore()
</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 cartStore.cartList" :key="i.id">
<td>
<el-checkbox />
</td>
<td>
<div class="goods">
<RouterLink to="/"><img :src="i.picture" alt="" /></RouterLink>
<div>
<p class="name ellipsis">
{{ i.name }}
</p>
</div>
</div>
</td>
<td class="tc">
<p>¥{{ i.price }}</p>
</td>
<td class="tc">
<el-input-number v-model="i.count" />
</td>
<td class="tc">
<p class="f16 red">¥{{ (i.price * i.count).toFixed(2) }}</p>
</td>
<td class="tc">
<p>
<el-popconfirm title="确认删除吗?" confirm-button-text="确认" cancel-button-text="取消" @confirm="delCart(i)">
<template #reference>
<a href="javascript:;">删除</a>
</template>
</el-popconfirm>
</p>
</td>
</tr>
<tr v-if="cartStore.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">
共 10 件商品,已选择 2 件,商品合计:
<span class="red">¥ 200.00 </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>
import cartlist from '@/views/CartList/index.vue'
{
path:'cartlist',
component:cartlist,
}
]
<el-button size="large" type="primary" @click="$router.push('/cartlist')">去购物车结算</el-button>
- 列表购物车单选功能
- 核心思路:把单选框状态和Pinia中store状态保持同步
- v-model指令不适合进行命令式的操作,所以把v-model回退为一般模式:model-value和@change的配合
const singleCheck = (skuId,selected) =>{
const item = cartList.value.find((item) => item.skuId===skuId)
item.selected = selected
}
return {
cartList,
addCart,
delCart,
allCount,
allPrice,
singleCheck
}
const singleCheck=(i,selected)=>{
console.log(i,selected)
cartStore.singleCheck(i.skuId,selected)
}
<!--单选框-->
<el-checkbox :model-value="i.selected" @change="(selected)=> singleCheck(i,selected)" />
- 购物车列表全选
- 操作单选决定多选:当cartStore所有选项为true时,isAll才为true
- 操作多选决定单选:cartList所有项的selected都要跟着变
const isAll = computed(()=> cartList.value.every((item)=>item.selected))
//全选功能
const checkAll = (selected) =>{
// 把cartList中的每一项的selected都设置为当前的全选框状态
cartList.value.forEach(item => item.selected = selected)
}
const checkAll =(selected) =>{
cartStore.checkAll(selected)
}
<el-checkbox :model-value="cartStore.isAll" @change="checkAll"/>
- 购物车列表统计数据实现
- 已选择数量 = cartList中的所有selected字段为true项的count之和
- 商品合计 = cartList中所有的selected字段为true项的count*price之和
const selectedCount = computed(()=>cartList.value.filter(item=>item.selected).reduce((a,c)=>a+c.count,0))
const selectedPrice = computed(()=>cartList.value.filter(item=>item.selected).reduce((a,c)=>a+c.count*c.price,0))
<div class="batch">
共 {{ cartStore.allCount }} 件商品,已选择 {{ cartStore.selectedCount }} 件,商品合计:
<span class="red">¥ {{ cartStore.selectedPrice.toFixed(2) }} </span>
</div>
- 加入购物车
- 找到加入购物车的action函数
- 判断是否登录,若是:
- 调用加入购物车接口
- 调用获取购物车列表接口
- 用接口购物车列表覆盖本地购物车列表
import request from '@/utils/https'
export const insertCartAPI =({skuId,count}) =>{
return request({
url:'/member/cart',
method:'POST',
data:{
skuId,
count
}
})
}
export const findNewCartListAPI=()=>{
return request({
url:'/member/cart'
})
}
//2.定义action - addCart
const addCart = async (goods) => {
const { skuId,count } = goods
if(isLogin.value){
await insertCartAPI({ skuId,count })
const res = await findNewCartListAPI()
cartList.value = res.result
}else{
// 添加购物车操作
// 已添加过 - count + 1
// 没有添加过 - 直接push
// 思路:通过匹配传递过来的商品对象中的skuId能不能在cartList中找到,找到了就是添加过
const item = cartList.value.find((item) => goods.skuId === item.skuId)
if (item) {
// 找到了
item.count++
} else {
// 没找到
cartList.value.push(goods)
}
}
}
- 删除购物车
- 找到删除购物车的action函数
- 判断是否登录,如果是
- 调用删除购物车的接口
- 调用获取购物车列表接口
- 用接口购物车列表覆盖本地购物车列表
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 updateNewList =async() =>{
const res = await findNewCartListAPI()
cartList.value = res.result
}
export const delCartAPI = (ids) =>{
return request({
url:'/member/cart',
method:'DELETE',
data:{
ids
}
})
}
- 清空购物车信息
- 业务需要:在用户退出登录时,除了清除用户信息之外,也需要把购物车数据清除
- 在购物车store中补充清除购物车的action
- userStore中找到退出登录action,执行清理业务
//清除列表操作 const clearCart = () =>{ cartList.value=[] }
import { useCartStore } from "./cartStore"
export const useUserStore = defineStore('user',()=>{
const cartStore = useCartStore()
const userInfo = ref({})
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,
})
- 合并购物车操作
- 为了在未登录的时候的操作做效
- 在用户登录时,把本地购物车与服务端购物车数据进行合并
- 登录时调用合并购物车接口
- 获取最新的购物车列表
- 覆盖本地购物车列表
export const mergeCartAPI = (data) => {
return request({
url:'/member/cart/merge',
method:'POST',
data
})
}
import { defineStore } from "pinia";
import { ref } from 'vue';
import { loginAPI } from "@/apis/user";
import { useCartStore } from "./cartStore"
import { mergeCartAPI } from "@/apis/cart";
export const useUserStore = defineStore('user',()=>{
const cartStore = useCartStore()
const userInfo = ref({})
const getUserInfo = async({ account,password })=>{
const res = await loginAPI ({ account,password })
userInfo.value = res.result
//合并购物车的操作
await mergeCartAPI(cartStore.cartList.map(item=>{
return{
skuId:item.skuId,
selected:item.selected,
count:item.count
}
}))
cartStore.updateNewList()
}
const clearUserInfo =()=>{
userInfo.value = {}
cartStore.clearCart()
}
return{
userInfo,
getUserInfo,
clearUserInfo
}
},{
persist: true,
})
结算模块
- 路由配置和基础数据渲染
- 路由配置:
- 准备路由组件
- 配置路由关系
- 配置路由跳转
- 基础组件渲染:
- 准备接口
- 获取数据
- 渲染默认地址和商品列表以及统计数据
- 路由配置:
import request from '@/utils/https'
export const getCheckInfoAPI =() => {
return request({
url:'/member/order/pre'
})
}
import checkout from '@/views/Checkout/index.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path:'/',
component:layout,
children:[
{
path:'',
component:home,
},
{
path:'category/:id',
component:category,
},
{
path:'category/sub/:id',
component:subcategory,
},
{
path:'detail/:id',
component:detail,
},
{
path:'cartlist',
component:cartlist,
},
{
path:'checkout',
component:checkout
}
]
},
{
path:'/login',
component:login
},
],
//路由行为定制
scrollBehavior(){
return{
top:0
}
}
})
<script setup>
import { getCheckInfoAPI } from '@/apis/checkout.js'
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())
</script>
<template>
<div class="xtx-pay-checkout-page">
<div class="container">
<div class="wrapper">
<!-- 收货地址 -->
<h3 class="box-title">收货地址</h3>
<div class="box-body">
<div class="address">
<div class="text">
<div class="none" v-if="!curAddress">您需要先添加收货地址才可提交订单。</div>
<ul v-else>
<li><span>收<i />货<i />人:</span>{{ curAddress.receiver }}</li>
<li><span>联系方式:</span>{{ curAddress.contact }}</li>
<li><span>收货地址:</span>{{ curAddress.fullLocation }} {{ curAddress.address }}</li>
</ul>
</div>
<div class="action">
<el-button size="large" @click="toggleFlag = true">切换地址</el-button>
<el-button size="large" @click="addFlag = true">添加地址</el-button>
</div>
</div>
</div>
<!-- 商品信息 -->
<h3 class="box-title">商品信息</h3>
<div class="box-body">
<table class="goods">
<thead>
<tr>
<th width="520">商品信息</th>
<th width="170">单价</th>
<th width="170">数量</th>
<th width="170">小计</th>
<th width="170">实付</th>
</tr>
</thead>
<tbody>
<tr v-for="i in checkInfo.goods" :key="i.id">
<td>
<a href="javascript:;" class="info">
<img :src="i.picture" alt="">
<div class="right">
<p>{{ i.name }}</p>
<p>{{ i.attrsText }}</p>
</div>
</a>
</td>
<td>¥{{ i.price }}</td>
<td>{{ i.price }}</td>
<td>¥{{ i.totalPrice }}</td>
<td>¥{{ i.totalPayPrice }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 配送时间 -->
<h3 class="box-title">配送时间</h3>
<div class="box-body">
<a class="my-btn active" href="javascript:;">不限送货时间:周一至周日</a>
<a class="my-btn" href="javascript:;">工作日送货:周一至周五</a>
<a class="my-btn" href="javascript:;">双休日、假日送货:周六至周日</a>
</div>
<!-- 支付方式 -->
<h3 class="box-title">支付方式</h3>
<div class="box-body">
<a class="my-btn active" href="javascript:;">在线支付</a>
<a class="my-btn" href="javascript:;">货到付款</a>
<span style="color:#999">货到付款需付5元手续费</span>
</div>
<!-- 金额明细 -->
<h3 class="box-title">金额明细</h3>
<div class="box-body">
<div class="total">
<dl>
<dt>商品件数:</dt>
<dd>{{ checkInfo.summary?.goodsCount }}件</dd>
</dl>
<dl>
<dt>商品总价:</dt>
<dd>¥{{ checkInfo.summary?.totalPrice.toFixed(2) }}</dd>
</dl>
<dl>
<dt>运<i></i>费:</dt>
<dd>¥{{ checkInfo.summary?.postFee.toFixed(2) }}</dd>
</dl>
<dl>
<dt>应付总额:</dt>
<dd class="price">{{ checkInfo.summary?.totalPayPrice.toFixed(2) }}</dd>
</dl>
</div>
</div>
<!-- 提交订单 -->
<div class="submit">
<el-button type="primary" size="large" >提交订单</el-button>
</div>
</div>
</div>
</div>
<!-- 切换地址 -->
<!-- 添加地址 -->
</template>
<style scoped lang="scss">
.xtx-pay-checkout-page {
margin-top: 20px;
.wrapper {
background: #fff;
padding: 0 20px;
.box-title {
font-size: 16px;
font-weight: normal;
padding-left: 10px;
line-height: 70px;
border-bottom: 1px solid #f5f5f5;
}
.box-body {
padding: 20px 0;
}
}
}
.address {
border: 1px solid #f5f5f5;
display: flex;
align-items: center;
.text {
flex: 1;
min-height: 90px;
display: flex;
align-items: center;
.none {
line-height: 90px;
color: #999;
text-align: center;
width: 100%;
}
>ul {
flex: 1;
padding: 20px;
li {
line-height: 30px;
span {
color: #999;
margin-right: 5px;
>i {
width: 0.5em;
display: inline-block;
}
}
}
}
>a {
color: $xtxColor;
width: 160px;
text-align: center;
height: 90px;
line-height: 90px;
border-right: 1px solid #f5f5f5;
}
}
.action {
width: 420px;
text-align: center;
.btn {
width: 140px;
height: 46px;
line-height: 44px;
font-size: 14px;
&:first-child {
margin-right: 10px;
}
}
}
}
.goods {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
.info {
display: flex;
text-align: left;
img {
width: 70px;
height: 70px;
margin-right: 20px;
}
.right {
line-height: 24px;
p {
&:last-child {
color: #999;
}
}
}
}
tr {
th {
background: #f5f5f5;
font-weight: normal;
}
td,
th {
text-align: center;
padding: 20px;
border-bottom: 1px solid #f5f5f5;
&:first-child {
border-left: 1px solid #f5f5f5;
}
&:last-child {
border-right: 1px solid #f5f5f5;
}
}
}
}
.my-btn {
width: 228px;
height: 50px;
border: 1px solid #e4e4e4;
text-align: center;
line-height: 48px;
margin-right: 25px;
color: #666666;
display: inline-block;
&.active,
&:hover {
border-color: $xtxColor;
}
}
.total {
dl {
display: flex;
justify-content: flex-end;
line-height: 50px;
dt {
i {
display: inline-block;
width: 2em;
}
}
dd {
width: 240px;
text-align: right;
padding-right: 70px;
&.price {
font-size: 20px;
color: $priceColor;
}
}
}
}
.submit {
text-align: right;
padding: 60px;
border-top: 1px solid #f5f5f5;
}
.addressWrapper {
max-height: 500px;
overflow-y: auto;
}
.text {
flex: 1;
min-height: 90px;
display: flex;
align-items: center;
&.item {
border: 1px solid #f5f5f5;
margin-bottom: 10px;
cursor: pointer;
&.active,
&:hover {
border-color: $xtxColor;
background: lighten($xtxColor, 50%);
}
>ul {
padding: 10px;
font-size: 14px;
line-height: 30px;
}
}
}
</style>
<div class="total">
<el-button size="large" type="primary" @click="$router.push('/checkout')">下单结算</el-button>
</div>
- 地址切换
- 打开弹窗交互实现
- 准备弹框组件
- 组件v-model绑定响应式数据
- 点击按钮控制响应式数据
- 渲染可选地址列表
- 地址激活交互实现
- 点击时记录一个当前激活地址对象activeAdress,点击哪个地址就把哪个地址记录下来
- 通过动态类名:class控制激活样式类型active是否存在,判断条件为:激活地址对象id===当前项id
- 打开弹窗交互实现
<script setup>
import { getCheckInfoAPI } from '@/apis/checkout.js'
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 activeAdress = ref({})
const switchAdress = (item) =>{
activeAdress.value=item
}
const confirm = () =>{
curAddress.value = activeAdress.value
showDialog.value = false
activeAdress.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">切换地址</el-button>
<el-button size="large" @click="addAdress = true">添加地址</el-button>
</div>
</div>
</div>
<!-- 商品信息 -->
<h3 class="box-title">商品信息</h3>
<div class="box-body">
<table class="goods">
<thead>
<tr>
<th width="520">商品信息</th>
<th width="170">单价</th>
<th width="170">数量</th>
<th width="170">小计</th>
<th width="170">实付</th>
</tr>
</thead>
<tbody>
<tr v-for="i in checkInfo.goods" :key="i.id">
<td>
<a href="javascript:;" class="info">
<img :src="i.picture" alt="">
<div class="right">
<p>{{ i.name }}</p>
<p>{{ i.attrsText }}</p>
</div>
</a>
</td>
<td>¥{{ i.price }}</td>
<td>{{ i.price }}</td>
<td>¥{{ i.totalPrice }}</td>
<td>¥{{ i.totalPayPrice }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 配送时间 -->
<h3 class="box-title">配送时间</h3>
<div class="box-body">
<a class="my-btn active" href="javascript:;">不限送货时间:周一至周日</a>
<a class="my-btn" href="javascript:;">工作日送货:周一至周五</a>
<a class="my-btn" href="javascript:;">双休日、假日送货:周六至周日</a>
</div>
<!-- 支付方式 -->
<h3 class="box-title">支付方式</h3>
<div class="box-body">
<a class="my-btn active" href="javascript:;">在线支付</a>
<a class="my-btn" href="javascript:;">货到付款</a>
<span style="color:#999">货到付款需付5元手续费</span>
</div>
<!-- 金额明细 -->
<h3 class="box-title">金额明细</h3>
<div class="box-body">
<div class="total">
<dl>
<dt>商品件数:</dt>
<dd>{{ checkInfo.summary?.goodsCount }}件</dd>
</dl>
<dl>
<dt>商品总价:</dt>
<dd>¥{{ checkInfo.summary?.totalPrice.toFixed(2) }}</dd>
</dl>
<dl>
<dt>运<i></i>费:</dt>
<dd>¥{{ checkInfo.summary?.postFee.toFixed(2) }}</dd>
</dl>
<dl>
<dt>应付总额:</dt>
<dd class="price">{{ checkInfo.summary?.totalPayPrice.toFixed(2) }}</dd>
</dl>
</div>
</div>
<!-- 提交订单 -->
<div class="submit">
<el-button type="primary" size="large" >提交订单</el-button>
</div>
</div>
</div>
</div>
<!-- 切换地址 -->
<el-dialog v-model="showDialog" title="切换收货地址" width="30%" center>
<div class="addressWrapper">
<div class="text item" :class="{ active:activeAdress.id === item.id }" @click="switchAdress(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" @click="confirm">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 添加地址 -->
</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>
- 生成订单功能实现
- 业务需求
- 调用接口生成id,并携带id跳转
- 调用更新购物车列表接口,更新购物车状态
- 步骤
- 准备支付页
- 封装生成订单接口
- 点击按钮调用接口,得到id,携带id完成路由跳转
- 业务需求
export const createOrderAPI = (data) =>{
return request({
url:'/member/order',
method:'POST',
data
})
}
<script setup>
import { getCheckInfoAPI,createOrderAPI } from '@/apis/checkout.js'
import { useRouter } from 'vue-router';
import { onMounted,ref } from 'vue';
import { useCartStore } from '@/stores/cartStore';
const router =useRouter()
const cartStore = useCartStore()
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 activeAdress = ref({})
const switchAdress = (item) =>{
activeAdress.value=item
}
const confirm = () =>{
curAddress.value = activeAdress.value
showDialog.value = false
activeAdress.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>
<!-- 提交订单 -->
<div class="submit">
<el-button @click="createOrder" type="primary" size="large" >提交订单</el-button>
</div>