Vue3——RabbitShopping

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 目录

文件列表

文件路径 说明
index.js 懒加载指令插件

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 目录

文件列表

文件路径 说明
index.js 路由配置

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 目录

文件列表

文件路径 说明
http.js axios封装

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>&yen;{{ i.price }}</p></td>
              <td class="tc"><el-input-number v-model="i.count" /></td>
              <td class="tc"><p class="f16 red">&yen;{{ (i.price * i.count).toFixed(2) }}</p></td>
              <td class="tc">
                <p>
                  <el-popconfirm title="确认删除吗?" @confirm="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;">&yen;{{ 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>&yen;{{ i.price }}</td>
                <td>{{ i.count }}</td>
                <td>&yen;{{ i.totalPrice }}</td>
                <td>&yen;{{ i.totalPayPrice }}</td>
              </tr>
            </tbody>
          </table>
        </div>
        <h3 class="box-title">配送时间</h3>
        <div class="box-body">
          <a class="my-btn active" href="javascript:;">不限送货时间:周一至周日</a>
          <a class="my-btn" href="javascript:;">工作日送货:周一至周五</a>
          <a class="my-btn" href="javascript:;">双休日、假日送货:周六至周日</a>
        </div>
        <h3 class="box-title">支付方式</h3>
        <div class="box-body">
          <a class="my-btn active" href="javascript:;">在线支付</a>
          <a class="my-btn" href="javascript:;">货到付款</a>
        </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 &copy; 小兔鲜儿</p>
      </div>
    </footer>
  </div>
</template>

<style scoped lang='scss'>
.login-header { background: #fff; border-bottom: 1px solid #e4e4e4;
  .container { display: flex; align-items: flex-end; justify-content: space-between; }
  .logo { width: 200px; a { display: block; height: 132px; width: 100%; text-indent: -9999px; background: url("@/assets/images/logo.png") no-repeat center 18px / contain; } }
  .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>

src/views/Layout/components/LayoutHeader.vue

页头组件,包含:

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

src/views/Layout/components/LayoutFooter.vue

页脚组件,纯展示型组件(无 <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/coreuseScroll 监听页面滚动。当滚动距离超过 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>

src/views/Layout/components/HeaderCart.vue

头部购物车下拉面板组件,功能包括:

  • 商品列表 :遍历 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">&yen;{{ 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>&yen; {{ 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>

相关推荐
BumBle2 小时前
从声明式到命令式:Vue3 弹窗组件的工厂模式重构
前端
恋猫de小郭2 小时前
你的蓝牙设备可能正在泄漏你的隐私? Bluehood 如何追踪附近设备并做隐私分析
android·前端·ios
取名不易2 小时前
vue-drawer-board 简单的画图功能
前端
学习指针路上的小学渣3 小时前
JavaScript笔记
前端·javascript
取名不易3 小时前
在 nuxtjs中通过fabric.js实现画图功能
前端
冰珊孤雪3 小时前
Android Studio Panda革命性升级:内存诊断、构建标准化与AI调试全解析
android·前端
coder_Eight3 小时前
彻底吃透 Promise:从状态、链式到手写实现,再到 async/await 底层原理
javascript·面试
用户806138166593 小时前
避免滥用“事件总线”
前端
Xiaoke3 小时前
我终于搞懂了 Event Loop(宏任务 / 微任务)
前端