src 项目总结构
复制代码
src/
├── App.vue # 根组件
├── main.js # 应用入口(注册 Pinia、Router、全局样式)
│
├── apis/ # API 接口层
│ ├── cart.js # 购物车接口
│ ├── category.js # 分类接口
│ ├── checkout.js # 结算接口
│ ├── detail.js # 商品详情接口
│ ├── home.js # 首页接口
│ ├── layout.js # 布局(分类导航)接口
│ ├── order.js # 订单接口
│ ├── pay.js # 支付接口
│ ├── test.js # 测试接口
│ └── user.js # 用户接口
│
├── assets/ # 静态资源
│ └── images/
│ ├── 200.png
│ ├── center-bg.png
│ ├── load.gif
│ ├── loading.gif
│ ├── login-bg.png
│ ├── logo.png
│ ├── none.png
│ └── qrcode.jpg
│
├── components/ # 全局公共组件
│ ├── ImageView/
│ │ └── index.vue # 图片预览组件
│ ├── XtxSku/
│ │ ├── index.vue # SKU 选择组件
│ │ └── power-set.js # SKU 幂集算法
│ └── index.js # 组件统一注册
│
├── composables/ # 组合式函数
│ └── uneCountDown.js # 倒计时逻辑
│
├── directives/ # 自定义指令
│ └── index.js # 懒加载指令 v-lazy
│
├── router/ # 路由配置
│ └── index.js # 路由表 + 路由守卫
│
├── stores/ # Pinia 状态管理
│ ├── cartStore.js # 购物车状态
│ ├── categoryStore.js # 分类数据状态
│ ├── counterStore.js # 计数器示例
│ └── userStore.js # 用户登录状态
│
├── styles/ # 全局样式
│ ├── common.scss # 公共样式
│ ├── var.scss # SCSS 变量
│ └── element/
│ └── index.scss # Element Plus 样式覆盖
│
├── utils/ # 工具函数
│ └── http.js # Axios 实例 + 请求/响应拦截器
│
└── views/ # 页面视图
├── CartList/
│ └── index.vue # 购物车列表页
├── Category/
│ ├── index.vue # 一级分类页
│ └── conposables/
│ ├── useBanner.js # Banner 数据逻辑
│ └── useCategory.js # 分类数据逻辑
├── Checkout/
│ └── index.vue # 结算页(收货地址 + 商品清单 + 提交订单)
├── Detail/
│ ├── index.vue # 商品详情页
│ └── components/
│ └── Detail-hot.vue # 热门推荐
├── Home/
│ ├── index.vue # 首页
│ └── components/
│ ├── GoodsItem.vue # 商品卡片
│ ├── HomeBanner.vue # 轮播图
│ ├── HomeCategory.vue # 分类导航
│ ├── HomeHot.vue # 热门推荐
│ ├── HomeNew.vue # 新鲜好物
│ ├── HomePanel.vue # 面板容器
│ └── HomeProduct.vue # 产品列表
├── Layout/
│ ├── index.vue # 布局主组件
│ └── components/
│ ├── HeaderCart.vue # 头部购物车下拉
│ ├── LayoutFixed.vue # 吸顶导航
│ ├── LayoutFooter.vue # 页脚
│ ├── LayoutHeader.vue # 页头
│ └── LayoutNav.vue # 顶部导航栏
├── Login/
│ └── index.vue # 登录页
├── Member/
│ ├── index.vue # 会员中心页
│ └── components/
│ ├── UserInfo.vue # 用户信息 + 商品收藏
│ └── UserOrder.vue # 订单列表
├── Pay/
│ ├── index.vue # 支付页
│ └── PayBack.vue # 支付结果页
└── SubCategory/
└── index.vue # 二级分类页
统计
分类
数量
说明
目录
10
apis、assets、components、composables、directives、router、stores、styles、utils、views
API 文件
10
cart、category、checkout、detail、home、layout、order、pay、test、user
静态图片
8
png、gif、jpg 格式
全局组件
3
ImageView、XtxSku、统一注册入口
Pinia Store
4
cartStore、categoryStore、counterStore、userStore
页面视图
10
CartList、Category、Checkout、Detail、Home、Layout、Login、Member、Pay、SubCategory
根级文件
2
App.vue、main.js
src/apis 目录
文件列表
文件名
说明
cart.js
购物车相关API
category.js
分类相关API
checkout.js
结算相关API
detail.js
商品详情相关API
home.js
首页相关API
layout.js
布局相关API
order.js
订单相关API
pay.js
支付相关API
test.js
测试API
user.js
用户相关API
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',
method: 'GET'
})
}
// 删除购物车
export const delCartAPI = (ids) => {
return httpInstance({
url: '/member/cart',
method: 'DELETE',
data: {
ids
}
})
}
//合并购物车
export const mergeCartAPI = (data) => {
return httpInstance({
url: '/member/cart/merge',
method: 'POST',
data
})
}
src/apis/category.js
javascript
复制代码
import httpInstance from '@/utils/http'
export function getCategoryAPI (id){
return httpInstance({
url: '/category',
params: {
id
}
})
}
/**
* @description: 获取二级分类列表数据
* @param {*} id 分类id
* @return {*}
*/
export const getCategoryFilterAPI = (id) => {
return httpInstance({
url:'/category/sub/filter',
params:{
id
}
})
}
/**
* @description: 获取导航数据
* @data {
categoryId: 1005000 ,
page: 1,
pageSize: 20,
sortField: 'publishTime' | 'orderNum' | 'evaluateNum'
}
* @return {*}
*/
export const getSubCategoryAPI = (data) => {
return httpInstance({
url:'/category/goods/temporary',
method:'POST',
data
})
}
src/apis/checkout.js
javascript
复制代码
import httpInstance from '@/utils/http'
//获取详情接口
export const getCheckInfoAPI = () => {
return httpInstance({
url: '/member/order/pre'
})
}
//提交订单接口
export const createOrderAPI = (data) => {
return httpInstance({
url: '/member/order',
method: 'POST',
data
})
}
src/apis/detail.js
javascript
复制代码
import httpInstance from '@/utils/http'
export const getDetail = (id) => {
return httpInstance({
url: '/goods',
params: {
id
}
})
}
/**
* 获取热榜商品
* @param {Number} id - 商品id
* @param {Number} type - 1代表24小时热销榜 2代表周热销榜
* @param {Number} limit - 获取个数
*/
export const fetchHotGoodsAPI = ({ id, type, limit = 3 }) => {
return httpInstance({
url:'/goods/hot',
params:{
id,
type,
limit
}
})
}
src/apis/home.js
javascript
复制代码
import httpInstance from '@/utils/http'
//获取banner
export function getBannerAPI( params = {}) {
//默认为1,商品为2
const {distributionSite = '1' } = params
return httpInstance({
url: '/home/banner',
params: {
distributionSite
}
})
}
/**
* @description: 获取新鲜好物
* @param {*}
* @return {*}
*/
export function findNewAPI() {
return httpInstance({
url: '/home/new'
})
}
/**
* @description: 获取人气推荐
* @param {*}
* @return {*}
*/
export function getHotAPI() {
return httpInstance({
url: '/home/hot'
})
}
/**
* @description: 获取所有商品模块
* @param {*}
* @return {*}
*/
export const getGoodsAPI = () => {
return httpInstance({
url: '/home/goods'
})
}
src/apis/layout.js
javascript
复制代码
import httpInstance from '@/utils/http'
export function getCategoryAPI () {
return httpInstance({
url: '/home/category/head'
})
}
src/apis/order.js
javascript
复制代码
import httpInstance from '@/utils/http'
/*
params: {
orderState:0,
page:1,
pageSize:2
}
*/
export const getUserOrder = (params) => {
return httpInstance({
url: '/member/order',
method: 'GET',
params,
timeout: 50000
})
}
src/apis/pay.js
javascript
复制代码
import httpInstance from '@/utils/http'
export const getOrderAPI = (id) => {
return httpInstance({
url: `/member/order/${id}`,
method: 'GET'
})
}
src/apis/test.js
javascript
复制代码
import http from '@/utils/http'
export function getCategoryAPI () {
return http({
url: 'home/category/head'
})
}
src/apis/user.js
javascript
复制代码
import httpInstance from '@/utils/http'
export const loginAPI = ({ account, password }) => {
return httpInstance({
method: 'POST',
url: '/login',
data: {
account,
password
}
})
}
export const getLikeListAPI = ({ limit = 4 }) => {
return httpInstance({
url: '/goods/relevant',
params: {
limit
}
})
}
src/components 目录
文件列表
文件路径
说明
index.js
组件全局注册
ImageView/index.vue
图片预览组件
XtxSku/index.vue
SKU选择组件
XtxSku/power-set.js
幂集算法工具
src/components/index.js
javascript
复制代码
//全局化注册
import GoodSku from './XtxSku/index.vue'
import ImageView from './ImageView/index.vue'
export const componentPlugin = {
install(app) {
app.component('GoodSku', GoodSku)
app.component('ImageView', ImageView)
}
}
src/components/ImageView/index.vue
vue
复制代码
<script setup>
import { ref, watch } from 'vue'
import { useMouseInElement } from '@vueuse/core'
// props适配图片列表
const props = defineProps({
imageList: {
type: Array,
default: () => []
}
})
// 1.小图切换大图显示
const activeIndex = ref(0)
const enterhandler = (i) => {
activeIndex.value = i
}
// 监听图片列表变化,重置 activeIndex
watch(() => props.imageList, () => {
activeIndex.value = 0
})
// 2. 获取鼠标相对位置
const target = ref(null)
const { elementX, elementY, isOutside } = useMouseInElement(target)
// 3. 控制滑块跟随鼠标移动(监听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[activeIndex]})`,
backgroundPositionX: `${positionX}px`,
backgroundPositionY: `${positionY}px`,
},
]" v-show="!isOutside"></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/components/XtxSku/index.vue
vue
复制代码
<template>
<div class="goods-sku">
<dl v-for="item in goods.specs" :key="item.id">
<dt>{{ item.name }}</dt>
<dd>
<template v-for="val in item.values" :key="val.name">
<img :class="{ selected: val.selected, disabled: val.disabled }" @click="clickSpecs(item, val)"
v-if="val.picture" :src="val.picture" />
<span :class="{ selected: val.selected, disabled: val.disabled }" @click="clickSpecs(item, val)" v-else>{{
val.name
}}</span>
</template>
</dd>
</dl>
</div>
</template>
<script>
import { watchEffect } from 'vue'
import getPowerSet from './power-set'
const spliter = '★'
// 根据skus数据得到路径字典对象
const getPathMap = (skus) => {
const pathMap = {}
if (skus && skus.length > 0) {
skus.forEach(sku => {
// 1. 过滤出有库存有效的sku
if (sku.inventory) {
// 2. 得到sku属性值数组
const specs = sku.specs.map(spec => spec.valueName)
// 3. 得到sku属性值数组的子集
const powerSet = getPowerSet(specs)
// 4. 设置给路径字典对象
powerSet.forEach(set => {
const key = set.join(spliter)
// 如果没有就先初始化一个空数组
if (!pathMap[key]) {
pathMap[key] = []
}
pathMap[key].push(sku.id)
})
}
})
}
return pathMap
}
// 初始化禁用状态
function initDisabledStatus (specs, pathMap) {
if (specs && specs.length > 0) {
specs.forEach(spec => {
spec.values.forEach(val => {
// 设置禁用状态
val.disabled = !pathMap[val.name]
})
})
}
}
// 得到当前选中规格集合
const getSelectedArr = (specs) => {
const selectedArr = []
specs.forEach((spec, index) => {
const selectedVal = spec.values.find(val => val.selected)
if (selectedVal) {
selectedArr[index] = selectedVal.name
} else {
selectedArr[index] = undefined
}
})
return selectedArr
}
// 更新按钮的禁用状态
const updateDisabledStatus = (specs, pathMap) => {
// 遍历每一种规格
specs.forEach((item, i) => {
// 拿到当前选择的项目
const selectedArr = getSelectedArr(specs)
// 遍历每一个按钮
item.values.forEach(val => {
if (!val.selected) {
selectedArr[i] = val.name
// 去掉undefined之后组合成key
const key = selectedArr.filter(value => value).join(spliter)
val.disabled = !pathMap[key]
}
})
})
}
export default {
name: 'XtxGoodSku',
props: {
// specs:所有的规格信息 skus:所有的sku组合
goods: {
type: Object,
default: () => ({ specs: [], skus: [] })
}
},
emits: ['change'],
setup (props, { emit }) {
let pathMap = {}
watchEffect(() => {
// 得到所有字典集合
pathMap = getPathMap(props.goods.skus)
// 组件初始化的时候更新禁用状态
initDisabledStatus(props.goods.specs, pathMap)
})
const clickSpecs = (item, val) => {
if (val.disabled) return false
// 选中与取消选中逻辑
if (val.selected) {
val.selected = false
} else {
item.values.forEach(bv => { bv.selected = false })
val.selected = true
}
// 点击之后再次更新选中状态
updateDisabledStatus(props.goods.specs, pathMap)
// 把选择的sku信息传出去给父组件
// 触发change事件将sku数据传递出去
const selectedArr = getSelectedArr(props.goods.specs).filter(value => value)
// 如果选中得规格数量和传入得规格总数相等则传出完整信息(都选择了)
// 否则传出空对象
if (selectedArr.length === props.goods.specs.length) {
// 从路径字典中得到skuId
const skuId = pathMap[selectedArr.join(spliter)][0]
const sku = props.goods.skus.find(sku => sku.id === skuId)
// 传递数据给父组件
emit('change', {
skuId: sku.id,
price: sku.price,
oldPrice: sku.oldPrice,
inventory: sku.inventory,
specsText: sku.specs.reduce((p, n) => `${p} ${n.name}:${n.valueName}`, '').trim()
})
} else {
emit('change', {})
}
}
return { clickSpecs }
}
}
</script>
<style scoped lang="scss">
@mixin sku-state-mixin {
border: 1px solid #e4e4e4;
margin-right: 10px;
cursor: pointer;
&.selected {
border-color: $xtxColor;
}
&.disabled {
opacity: 0.6;
border-style: dashed;
cursor: not-allowed;
}
}
.goods-sku {
padding-left: 10px;
padding-top: 20px;
dl {
display: flex;
padding-bottom: 20px;
align-items: center;
dt {
width: 50px;
color: #999;
}
dd {
flex: 1;
color: #666;
>img {
width: 50px;
height: 50px;
margin-bottom: 4px;
@include sku-state-mixin;
}
>span {
display: inline-block;
height: 30px;
line-height: 28px;
padding: 0 20px;
margin-bottom: 4px;
@include sku-state-mixin;
}
}
}
}
</style>
src/components/XtxSku/power-set.js
javascript
复制代码
export default function bwPowerSet (originalSet) {
const subSets = []
// We will have 2^n possible combinations (where n is a length of original set).
// It is because for every element of original set we will decide whether to include
// it or not (2 options for each set element).
const numberOfCombinations = 2 ** originalSet.length
// Each number in binary representation in a range from 0 to 2^n does exactly what we need:
// it shows by its bits (0 or 1) whether to include related element from the set or not.
// For example, for the set {1, 2, 3} the binary number of 0b010 would mean that we need to
// include only "2" to the current set.
for (let combinationIndex = 0; combinationIndex < numberOfCombinations; combinationIndex += 1) {
const subSet = []
for (let setElementIndex = 0; setElementIndex < originalSet.length; setElementIndex += 1) {
// Decide whether we need to include current element into the subset or not.
if (combinationIndex & (1 << setElementIndex)) {
subSet.push(originalSet[setElementIndex])
}
}
// Add current subset to the list of all subsets.
subSets.push(subSet)
}
return subSets
}
src/composables 目录
文件列表
文件路径
说明
uneCountDown.js
倒计时逻辑封装
src/composables/uneCountDown.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(() => {
if (timer) {
clearInterval(timer)
}
})
return {
formatTime,
start
}
}
src/directives 目录
文件列表
src/directives/index.js
javascript
复制代码
// 定义懒加载插件
import { useIntersectionObserver } from '@vueuse/core'
export const lazyPlugin = {
install (app) {
// 懒加载指令逻辑
app.directive('img-lazy', {
mounted (el, binding) {
// el: 指令绑定的那个元素 img
// binding: binding.value 指令等于号后面绑定的表达式的值 图片url
const { stop } = useIntersectionObserver(
el,
([{ isIntersecting }]) => {
if (isIntersecting) {
// 进入视口区域
el.src = binding.value
stop()
}
},
)
}
})
}
}
src/router 目录
文件列表
src/router/index.js
javascript
复制代码
// createRouter:创建router实例对象
// createWebHistory:创建history模式的路由
import { createRouter, createWebHistory } from 'vue-router'
import Login from '@/views/Login/index.vue'
import Layout from '@/views/Layout/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'
import CartList from '@/views/CartList/index.vue'
import Checkout from '@/views/Checkout/index.vue'
import Pay from '@/views/Pay/index.vue'
import PayBack from '@/views/Pay/PayBack.vue'
import Member from '@/views/Member/index.vue'
import UserOrder from '@/views/Member/components/UserOrder.vue'
import UserInfo from '@/views/Member/components/UserInfo.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
// path和component对应关系的位置
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: '/pay',
component: Pay
},
{
path: '/paycallback',
component: PayBack
},
{
path: '/member',
component: Member,
children: [
{
path: 'order',
component: UserOrder
},
{
path: '',
component: UserInfo
}
]
}
]
},
{
path: '/login',
component: Login
}
],
scrollBehavior() {
return {
top: 0
}
}
})
export default router
src/stores 目录
文件列表
文件路径
说明
cartStore.js
购物车状态管理
categoryStore.js
分类状态管理
counterStore.js
计数器状态管理
userStore.js
用户状态管理
src/stores/cartStore.js
javascript
复制代码
// 封装购物车模块
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useUserStore } from './userStore'
import { insertCartAPI, findNewCartListAPI, delCartAPI } from '@/apis/cart'
export const useCartStore = defineStore('cart', () => {
// 引入userStore
const userStore = useUserStore()
const isLogin = computed(() => userStore.userInfo.token)
// 1. 定义state - cartList
const cartList = ref([])
//获取最新购物车列表函数
const updateNewList = async () => {
const res = await findNewCartListAPI()
cartList.value = res.result
}
// 2. 定义action - addCart
const addCart = async (goods) => {
if (isLogin.value) {
//登陆后的购物车逻辑
await insertCartAPI({ skuId: goods.skuId, count: goods.count })
await updateNewList()
} 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)
}
}
}
// 删除购物车
const delCart = async (skuId) => {
if (isLogin.value) {
// 登陆后的购物车删除逻辑
await delCartAPI([skuId])
await updateNewList()
} else {
// 思路:找到要删除的那一项的索引,然后使用splice方法删除
// 或者使用filter方法过滤掉要删除的那一项,重新赋值给cartList
const idx = cartList.value.findIndex((item) => skuId === item.skuId)
cartList.value.splice(idx, 1)
}
}
//清除购物车
const clearCart = () => {
cartList.value = []
}
// 商品单选框改变时调用
const singleChange = (skuId, selected) => {
// 通过skuId找到要修改的那一项 然后把它的selected修改为传过来的selected
const item = cartList.value.find((item) => item.skuId === skuId)
item.selected = selected
}
//全选功能
const allChange = (selected) => {
cartList.value.forEach((item) => {
item.selected = selected
})
}
//计算属性
// 已选择数量
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.price * c.count, 0))
//是否全选
const isAll = computed(() => cartList.value.every((item) => item.selected))
return {
cartList,
addCart,
delCart,
singleChange,
isAll,
allChange,
selectedCount,
selectedPrice,
clearCart,
updateNewList
}
}, {
persist: true,
})
src/stores/categoryStore.js
javascript
复制代码
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { getCategoryAPI } from '@/apis/layout'
export const useCategoryStore = defineStore('category', () => {
const categoryList = ref([])
const getCatgory = async () => {
const res = await getCategoryAPI()
console.log(res)
categoryList.value = res.result
}
return {
categoryList,
getCatgory
}
})
src/stores/counterStore.js
javascript
复制代码
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
src/stores/userStore.js
javascript
复制代码
// 管理用户数据相关
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()
// 1. 定义管理用户数据的state
const userInfo = ref({})
// 2. 定义获取接口数据的action函数
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,
count: item.count,
selected: item.selected
}
}))
// 刷新购物车列表
await cartStore.updateNewList()
}
//退出时清除用户信息
const clearUserInfo = () => {
userInfo.value = {}
// 清除购物车
cartStore.clearCart()
}
// 3. 以对象的格式把state和action return
return {
userInfo,
getUserInfo,
clearUserInfo
}
}, {
persist: true,
})
src/styles 目录
文件列表
文件路径
说明
common.scss
通用样式重置
var.scss
样式变量定义
element/index.scss
Element Plus 主题定制
src/styles/common.scss
scss
复制代码
// 重置样式
* {
box-sizing: border-box;
}
html {
height: 100%;
font-size: 14px;
}
body {
height: 100%;
color: #333;
min-width: 1240px;
font: 1em/1.4 'Microsoft Yahei', 'PingFang SC', 'Avenir', 'Segoe UI',
'Hiragino Sans GB', 'STHeiti', 'Microsoft Sans Serif', 'WenQuanYi Micro Hei',
sans-serif;
}
body,
ul,
h1,
h3,
h4,
p,
dl,
dd {
padding: 0;
margin: 0;
}
a {
text-decoration: none;
color: #333;
outline: none;
}
i {
font-style: normal;
}
input[type='text'],
input[type='search'],
input[type='password'],
input[type='checkbox'] {
padding: 0;
outline: none;
border: none;
-webkit-appearance: none;
&::placeholder {
color: #ccc;
}
}
img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
background: #ebebeb url('@/assets/images/200.png') no-repeat center / contain;
}
ul {
list-style: none;
}
#app {
background: #f5f5f5;
user-select: none;
}
.container {
width: 1240px;
margin: 0 auto;
position: relative;
}
.ellipsis {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.ellipsis-2 {
word-break: break-all;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.fl {
float: left;
}
.fr {
float: right;
}
.clearfix:after {
content: '.';
display: block;
visibility: hidden;
height: 0;
line-height: 0;
clear: both;
}
// reset element
.el-breadcrumb__inner.is-link {
font-weight: 400 !important;
}
src/styles/var.scss
scss
复制代码
$xtxColor: #27ba9b;
$helpColor: #e26237;
$sucColor: #1dc779;
$warnColor: #ffb302;
$priceColor: #cf4444;
src/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,
),
)
)
src/utils 目录
文件列表
src/utils/http.js
javascript
复制代码
// axios基础封装
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/userStore'
import router from '@/router'
// 创建axios实例
const http = axios.create({
baseURL: 'http://pcapi-xiaotuxian-front-devtest.itheima.net',
timeout: 5000
})
// axios请求拦截器
http.interceptors.request.use(config => {
// 从pinia中获取token
const userStore = useUserStore()
const token = userStore.userInfo.token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
}, e => Promise.reject(e))
// axios响应式拦截器
http.interceptors.response.use(res => res.data, e => {
//统一错误提示
ElMessage({
type: 'warning',
message: e.response.data.message
})
//401token失效处理
//1.清除本地用户数据
//2.跳转到登录页
const userStore = useUserStore()
if (e.response.status === 401) {
userStore.clearUserInfo()
router.push('/login')
}
return Promise.reject(e)
})
export default http
src/views 目录
文件列表
文件路径
说明
CartList/index.vue
购物车列表页
Category/index.vue
一级分类页
Category/conposables/useBanner.js
轮播图逻辑
Category/conposables/useCategory.js
分类数据逻辑
Checkout/index.vue
结算页
Detail/index.vue
商品详情页
Detail/components/Detail-hot.vue
详情页热榜组件
Home/index.vue
首页
Home/components/GoodsItem.vue
商品组件
Home/components/HomeBanner.vue
首页轮播图
Home/components/HomeCategory.vue
首页分类导航
Home/components/HomeHot.vue
人气推荐
Home/components/HomeNew.vue
新鲜好物
Home/components/HomePanel.vue
面板组件
Home/components/HomeProduct.vue
商品列表
Layout/index.vue
布局容器
Layout/components/HeaderCart.vue
头部购物车
Layout/components/LayoutFixed.vue
固定头部
Layout/components/LayoutFooter.vue
底部footer
Layout/components/LayoutHeader.vue
头部导航
Layout/components/LayoutNav.vue
顶部导航
Login/index.vue
登录页
Member/index.vue
会员中心
Member/components/UserInfo.vue
用户信息
Member/components/UserOrder.vue
用户订单
Pay/index.vue
支付页
Pay/PayBack.vue
支付回调页
SubCategory/index.vue
二级分类页
src/views/CartList/index.vue
vue
复制代码
<script setup>
import { useCartStore } from '@/stores/cartStore'
const cartStore = useCartStore()
const singleChange = ( i , selected) => {
cartStore.singleChange(i.skuId, selected)
}
const allChange = (selected) => {
cartStore.allChange(selected)
}
</script>
<template>
<div class="xtx-cart-page">
<div class="container m-top-20">
<div class="cart">
<table>
<thead>
<tr>
<th width="120">
<el-checkbox :model-value="cartStore.isAll" @change="allChange" />
</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 v-model="i.selected" @change="(selected) => singleChange(i , selected)" />
</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="delCart(i)">
<template #reference><a href="javascript:;">删除</a></template>
</el-popconfirm>
</p>
</td>
</tr>
</tbody>
</table>
</div>
<div class="action">
<div class="batch">
共 {{ cartStore.cartList.length }} 件商品,已选择 {{ cartStore.selectedCount }} 件,商品合计:
<span class="red">¥ {{ cartStore.selectedPrice.toFixed(2) }} </span>
</div>
<div class="total">
<el-button size="large" type="primary" @click="$router.push('/checkout')">下单结算</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;
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; } }
.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; justify-content: space-between; padding: 0 30px;
.red { font-size: 18px; margin-right: 20px; font-weight: bold; }
}
}
</style>
src/views/Category/index.vue
vue
复制代码
<script setup>
import { useBanner } from './conposables/useBanner'
import { useCategory } from './conposables/useCategory'
const { bannerList } = useBanner()
const { categoryData } = useCategory()
</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="`/category/sub/${i.id}`">
<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">
<div v-for="g in item.goods" :key="g.id" style="width: 220px; text-align: center;">
<img :src="g.picture" style="width: 160px; height: 160px;" />
<p>{{ g.name }}</p>
<p style="color: red;">¥{{ g.price }}</p>
</div>
</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;
.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>
src/views/Checkout/index.vue
vue
复制代码
<script setup>
import { getCheckInfoAPI } from '@/apis/checkout'
import { ref, onMounted } from 'vue'
const checkInfo = ref({})
const curAddress = ref({})
const getCheckInfo = async () => {
try {
const res = await getCheckInfoAPI()
if (!res || !res.result) return
checkInfo.value = res.result
const item = checkInfo.value.userAddresses?.find(item => item.isDefault === 0)
if (item) { curAddress.value = item }
} catch (err) { console.error('获取订单信息失败:', err) }
}
onMounted(() => getCheckInfo())
const showDialog = ref(false)
const activeAddress = ref({})
const switchAddress = (item) => { activeAddress.value = item }
const confirmAddress = () => {
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">切换地址</el-button>
<el-button size="large">添加地址</el-button>
</div>
</div>
</div>
<h3 class="box-title">商品信息</h3>
<div class="box-body">
<table class="goods">
<thead>
<tr><th width="520">商品信息</th><th width="170">单价</th><th width="170">数量</th><th width="170">小计</th><th width="170">实付</th></tr>
</thead>
<tbody>
<tr v-for="i in checkInfo.goods" :key="i.id">
<td><a href="javascript:;" class="info"><img :src="i.picture" alt=""><div class="right"><p>{{ i.name }}</p><p>{{ i.attrsText }}</p></div></a></td>
<td>¥{{ i.price }}</td>
<td>{{ i.count }}</td>
<td>¥{{ i.totalPrice }}</td>
<td>¥{{ i.totalPayPrice }}</td>
</tr>
</tbody>
</table>
</div>
<h3 class="box-title">配送时间</h3>
<div class="box-body">
<a class="my-btn active" href="javascript:;">不限送货时间:周一至周日</a>
<a class="my-btn" href="javascript:;">工作日送货:周一至周五</a>
<a class="my-btn" href="javascript:;">双休日、假日送货:周六至周日</a>
</div>
<h3 class="box-title">支付方式</h3>
<div class="box-body">
<a class="my-btn active" href="javascript:;">在线支付</a>
<a class="my-btn" href="javascript:;">货到付款</a>
</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>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="confirmAddress">确定</el-button>
</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; } } } }
}
.action { width: 420px; text-align: center; }
}
.goods { width: 100%; border-collapse: collapse;
.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>
src/views/Detail/index.vue
vue
复制代码
<script setup>
import { getDetail } from '@/apis/detail'
import { onMounted, ref } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
import GoodHot from './components/Detail-hot.vue'
import { ElMessage } from 'element-plus'
import { useCartStore } from '@/stores/cartStore'
const cartStore = useCartStore()
const goods = ref({})
const route = useRoute()
const getGoods = async (id = route.params.id) => {
const res = await getDetail(id)
goods.value = res.result
}
onMounted(() => getGoods())
onBeforeRouteUpdate((to) => { getGoods(to.params.id) })
const count = ref(1)
let skuObj = {}
const skuChange = (sku) => { skuObj = sku }
const addCart = () => {
if (skuObj.skuId) {
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
})
ElMessage.success('添加成功')
} else { ElMessage.warning("请选择规格") }
}
</script>
<template>
<div class="xtx-goods-page">
<div class="container" v-if="goods.name">
<div class="bread-container">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="goods.categories?.[1]" :to="{ path: `/category/${goods.categories[1].id}` }">{{ goods.categories[1].name }}</el-breadcrumb-item>
<el-breadcrumb-item v-if="goods.categories?.[0]" :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 v-if="goods.mainPictures" :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.oldPrice }}</span><span> {{ goods.price }}</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>
<GoodSku v-if="goods.specs" :goods="goods" @change="skuChange" />
<el-input-number v-model="count" />
<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 goods.details?.properties" :key="item.value"><span class="dt">{{ item.name }}</span><span class="dd">{{ item.value }}</span></li>
</ul>
<img v-for="img in goods.details?.pictures" :src="img" :key="img" alt="">
</div>
</div>
</div>
<div class="goods-aside">
<GoodHot :type="1" /><GoodHot :type="2" />
</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; }
}
.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; } }
}
.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; } } }
}
}
}
.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; } } }
.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/views/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>
src/views/Login/index.vue
vue
复制代码
<script setup>
import { ref } from 'vue'
import router from '@/router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/userStore'
const userStore = useUserStore()
const userInfo = ref({ account: 'heima282', password: 'hm#qd@23!', 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, val, callback) => { if (val) { callback() } else { callback(new Error('请先同意协议')) } } }]
}
const formRef = ref(null)
const doLogin = () => {
formRef.value.validate(async (valid) => {
if (valid) {
await userStore.getUserInfo(userInfo.value).then(res => {
ElMessage({ type: 'success', message: '登录成功' })
router.replace('/')
})
}
})
}
</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 ref="formRef" :model="userInfo" :rules="rules" label-position="right" label-width="60px" status-icon>
<el-form-item prop="account" label="账户"><el-input v-model="userInfo.account" /></el-form-item>
<el-form-item prop="password" label="密码"><el-input v-model="userInfo.password" type="password" /></el-form-item>
<el-form-item prop="agree" label-width="22px"><el-checkbox v-model="userInfo.agree" size="large">我已同意隐私条款和服务条款</el-checkbox></el-form-item>
<el-button size="large" class="subBtn" @click="doLogin">点击登录</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; } }
.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; } } } }
.subBtn { background: $xtxColor; width: 100%; color: #fff; }
</style>
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">个人中心</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; } }
}
}
.article { width: 1000px; background-color: #fff; }
}
</style>
src/views/Member/components/UserOrder.vue
vue
复制代码
<script setup>
import { getUserOrder } from '@/apis/order'
import { ref, onMounted } from 'vue'
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 = { orderState: 0, page: 1, pageSize: 2 }
const total = ref(0)
const getOrderList = async () => {
const res = await getUserOrder(params)
orderList.value = res.result
total.value = res.result.counts
}
onMounted(() => { getOrderList() })
const tabChange = (type) => { params.orderState = type; getOrderList() }
const pageChange = (page) => { params.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">
<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>
<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; } } }
}
.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>
src/views/Pay/index.vue
vue
复制代码
<script setup>
import { getOrderAPI } from '@/apis/pay'
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useCountDown } from '@/composables/uneCountDown'
const { formatTime, start } = useCountDown()
start(14400)
const route = useRoute()
const payInfo = ref({})
const getPayInfo = async () => {
const res = await getOrderAPI(route.query.id)
payInfo.value = res.result
}
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>支付还剩 {{ formatTime }}, 超时后将取消订单</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/views/SubCategory/index.vue
vue
复制代码
<script setup>
import { getCategoryFilterAPI, getSubCategoryAPI } from '@/apis/category'
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import GoodsItem from '@/views/Home/components/GoodsItem.vue'
const filterData = ref({})
const getFilterData = async () => {
const res = await getCategoryFilterAPI(useRoute().params.id)
filterData.value = res.result
}
onMounted(() => getFilterData())
const goodList = ref([])
const reqData = ref({ categoryId: useRoute().params.id, page: 1, pageSize: 20, sortField: 'publishTime' })
const getGoodList = async () => {
const res = await getSubCategoryAPI(reqData.value)
goodList.value = res.result.items
}
onMounted(() => getGoodList())
const tabChange = () => { reqData.value.page = 1; getGoodList() }
const disabled = ref(false)
const load = async () => {
reqData.value.page++
const res = await getSubCategoryAPI(reqData.value)
goodList.value = [...goodList.value, ...res.result.items]
if (res.result.items.length === 0) { disabled.value = true }
}
</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: `/category/${filterData.parentId}` }">{{ filterData.parentName }}</el-breadcrumb-item>
<el-breadcrumb-item>{{ filterData.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="sub-container">
<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>
<div class="body" v-infinite-scroll="load">
<GoodsItem v-for="goods in goodList" :key="goods.id" :goods="goods" />
</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>
Layout 布局模块
目录结构
复制代码
src/views/Layout/
├── index.vue # 布局主组件
└── components/
├── LayoutNav.vue # 顶部导航栏(用户登录/退出)
├── LayoutHeader.vue # 页头(Logo + 分类导航 + 搜索 + 购物车)
├── LayoutFooter.vue # 页脚(联系信息 + 版权)
├── LayoutFixed.vue # 吸顶导航栏(滚动固定)
└── HeaderCart.vue # 头部购物车下拉面板
src/views/Layout/index.vue
布局主组件,组合所有布局子组件,并在挂载时触发分类数据加载。通过 :key="$route.fullPath" 破坏组件复用机制,确保路由切换时子组件重新渲染。
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 { onMounted } from 'vue'
import { useCategoryStore } from '@/stores/categoryStore';
const categoryStore = useCategoryStore()
onMounted(() => categoryStore.getCatgory()
)
</script>
<template>
<LayoutFixed />
<LayoutNav />
<LayoutHeader />
<RouterView :key="$route.fullPath" />
<LayoutFooter />
</template>
src/views/Layout/components/LayoutNav.vue
顶部导航栏,深色背景。根据用户登录状态显示不同内容:
已登录 :显示用户账号、退出登录(Element Plus Popconfirm)、我的订单、会员中心
未登录 :显示请先登录、帮助中心、关于我们
vue
复制代码
<script setup>
import { useUserStore } from '@/stores/userStore'
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>
<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>
页头组件,包含:
Logo :点击跳转首页
分类导航 :遍历 categoryStore.categoryList 渲染分类链接,支持 active-class 高亮
搜索框 :带图标的搜索输入框
HeaderCart :头部购物车组件
vue
复制代码
<script setup>
import { useCategoryStore } from '@/stores/categoryStore'
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">
<RouterLink exact-active-class="active" to="/">首页</RouterLink>
</li>
<li 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>
<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;
}
}
}
</style>
页脚组件,纯展示型组件(无 <script setup>),包含:
联系我们 :客户服务、关注我们、下载APP(二维码)、服务热线
品牌标语 :价格亲民、物流快捷、品质新鲜
版权信息 :底部链接和版权声明
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>
src/views/Layout/components/LayoutFixed.vue
吸顶导航栏组件,使用 @vueuse/core 的 useScroll 监听页面滚动。当滚动距离超过 78px 时,通过 CSS class show 触发过渡动画,将导航栏固定在页面顶部。
vue
复制代码
<script setup>
import { useCategoryStore } from '@/stores/categoryStore'
import { useScroll } from '@vueuse/core'
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 exact-active-class="active" to="/">首页</RouterLink>
</li>
<li v-for="item in categoryStore.categoryList" :key="item.id">
<RouterLink active-class="active" :to="`/category/${item.id}`">{{ item.name }}</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>
头部购物车下拉面板组件,功能包括:
商品列表 :遍历 cartStore.cartList 渲染购物车商品(图片、名称、属性、价格、数量)
删除商品 :点击关闭图标调用 cartStore.delCart(skuId) 删除
统计信息 :computed 计算总件数 totalCount 和总价 totalPrice
跳转结算 :点击按钮跳转到 /cartList 页面
悬停展开 :鼠标悬停时通过 CSS 过渡动画展开下拉面板
vue
复制代码
<script setup>
import { useCartStore } from '@/stores/cartStore';
import router from '@/router'
import { computed } from 'vue';
const cartStore = useCartStore()
const totalCount = computed(() => cartStore.cartList.reduce((sum, item) => sum + item.count, 0))
const totalPrice = computed(() => cartStore.cartList.reduce((sum, item) => sum + item.count * item.price, 0))
const toCheckout = () => {
router.push('/cartList')
}
</script>
<template>
<div class="cart">
<a class="curr" href="javascript:;">
<i class="iconfont icon-cart"></i><em>{{ totalCount }}</em>
</a>
<div class="layer">
<div class="list">
<div class="item" v-for="i in cartStore.cartList" :key="i.skuId">
<RouterLink :to="`/detail/${i.id}`">
<img :src="i.picture" alt="" />
<div class="center">
<p class="name ellipsis-2">
{{ i.name }}
</p>
<p class="attr ellipsis">{{ i.attrsText }}</p>
</div>
<div class="right">
<p class="price">¥{{ i.price }}</p>
<p class="count">x{{ i.count }}</p>
</div>
</RouterLink>
<i class="iconfont icon-close-new" @click="cartStore.delCart(i.skuId)"></i>
</div>
</div>
<div class="foot">
<div class="total">
<p>共 {{ totalCount }} 件商品</p>
<p>¥ {{ totalPrice.toFixed(2) }} </p>
</div>
<el-button size="large" type="primary" @click="toCheckout">去购物车结算</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>