模块五:交互深化 - 构建商品详情页与购物车
本模块任务清单
任务模块 | 任务名称 | 核心技术应用与学习要点 |
---|
后端与数据准备 | 利用现有 products 接口 | json-server : 无需修改!直接利用 json-server 自动生成的 GET /products/:id 接口来获取商品详情。 |
逻辑层先行 | 封装 useGoods Composable | TanStack Query , TypeScript : 创建 useGoods Composable 获取商品详情数据,并定义相应的类型接口。 |
页面骨架搭建 | 搭建商品详情页 (Detail ) 静态布局 | Vue Router , BEM : 搭建页面基础结构,包括商品信息区、详情展示区和热销推荐区。 |
原子组件:图片交互 | 实现 GoodsImage 组件 | @vueuse/core : 基于 useMouseInElement ,构建一个高性能的图片预览及放大镜组件。 |
【核心】客户端状态 | 第一步:创建 cartStore | Pinia , TypeScript : 创建 cartStore.ts ,定义 state、getters (总数、总价) 和 actions (添加、删除等) 的基本结构和类型。 |
【核心】客户端状态 | 第二步:实现“加入购物车” | Pinia , Element Plus : 在 Detail.vue 中,实现数量选择、点击按钮调用 cartStore 的 addCart action,并用 ElMessage 给出反馈。 |
UI 组件:购物车 | 实现 CartPanel 组件 | Pinia , Element Plus : 创建一个购物车面板组件,从 cartStore 获取数据并动态渲染,实现删除商品等交互。 |
页面整合与动态化 | 整合 Detail 页面,完成数据流闭环 | props , emits : 将所有子组件在 Detail.vue 中组装,通过 props 传入数据,响应事件,完成页面功能。 |
模块提交 | 提交模块成果 | Git : 提交一个功能完整的商品详情与购物车模块。 |
5.1 后端升级:创建全局商品接口
在构建商品详情页之前,我们需要一个能根据商品 ID 精确查询到单个商品所有信息的接口。回顾我们在模块四构建的 generate-data.js
,你会发现所有商品数据都嵌套在各自的分类之下,我们并没有一个顶层的、包含所有商品的 products
列表。
因此,json-server
无法自动为我们创建 GET /products/:id
这样的便捷接口。在本节,我们将对数据聚合脚本进行一次重要的升级,来解决这个问题。
当前任务: 5.1 - 创建全局商品接口
任务目标: 升级 scripts/generate-data.js
脚本,在聚合分类数据的同时,额外创建一个 包含所有商品的、平铺的 products
数组。利用这个新的数据结构,让 json-server
自动为我们提供 GET /products/:id
服务。
1. 设计思路:数据冗余以优化查询
API 设计
商品详情页开发前
架构师,我需要一个 GET /products/:id
接口,但是我们的 db.json
里没有顶级的 products
数组,json-server
生成不了这个路由,怎么办?
架构师
你发现了问题的关键。在真实的数据库设计中,这很常见。数据通常会以“规范化”的形式存储(比如按分类嵌套),但为了“查询性能”,我们经常会创建一些“非规范化”的、冗余的数据视图。
你的意思是,我们要在生成 db.json
的时候,既保留 categories
的嵌套结构,又 额外 创建一个包含所有商品的 products
数组?
架构师
完全正确!我们将在 generate-data.js
脚本中增加一步:每当它扫描到一个商品时,除了把它放进对应的分类里,同时 也把它放进一个全局的、顶层的 products
数组中。
我明白了!这样 db.json
中就有了 products
这个顶级资源。json-server
看到它,就会自动为我们创建 GET /products/:id
接口,问题就解决了!
架构师
是的。这就是用少量的数据冗余,换来 API 设计的简洁和查询效率的提升。
2. 升级 generate-data.js
脚本
我们将对脚本进行关键的重构。
请打开 scripts/generate-data.js
文件,并进行如下修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211
| const { faker } = require("@faker-js/faker"); const fs = require("fs"); const path = require("path");
const readCategoryBanners = (categoryId) => { const bannerPath = path.join( __dirname, `../public/data/${categoryId}/banner` );
if (fs.existsSync(bannerPath)) { return fs .readdirSync(bannerPath) .filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file)) .map((file, index) => ({ id: `${categoryId}-banner-${index + 1}`, imgUrl: `/data/${categoryId}/banner/${file}`, hrefUrl: `/category/${categoryId}`, })); } return []; };
const readImageFiles = (folderPath) => { if (!fs.existsSync(folderPath)) return []; return fs .readdirSync(folderPath) .filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file)) .sort(); };
const readCategoryData = (categoryId) => { const categoryPath = path.join(__dirname, `../public/data/${categoryId}`); if (!fs.existsSync(categoryPath)) return { subCategories: [], products: [] };
const subCategories = []; const products = []; const items = fs.readdirSync(categoryPath, { withFileTypes: true });
for (const item of items) { if (item.isDirectory() && !["banner", "Banner"].includes(item.name)) { const subCategoryPath = path.join(categoryPath, item.name); const subCategoryProducts = [];
const productDirs = fs .readdirSync(subCategoryPath, { withFileTypes: true }) .filter((d) => d.isDirectory());
for (const productDir of productDirs) { const productPath = path.join(subCategoryPath, productDir.name); const productInfoFile = path.join(productPath, "product_info.json");
let productInfo = { name: productDir.name }; if (fs.existsSync(productInfoFile)) { try { productInfo = JSON.parse(fs.readFileSync(productInfoFile, "utf-8")); } catch (e) {} }
const coverPath = path.join(productPath, "cover"); const galleryPath = path.join(productPath, "gallery"); const detailPath = path.join(productPath, "detail");
const coverImages = readImageFiles(coverPath); const galleryImages = readImageFiles(galleryPath); const detailImages = readImageFiles(detailPath);
const coverImage = coverImages.length > 0 ? `/data/${categoryId}/${item.name}/${productDir.name}/cover/${coverImages[0]}` : galleryImages.length > 0 ? `/data/${categoryId}/${item.name}/${productDir.name}/gallery/${galleryImages[0]}` : "";
const gallery = galleryImages.map( (img) => `/data/${categoryId}/${item.name}/${productDir.name}/gallery/${img}` );
const details = detailImages.map( (img) => `/data/${categoryId}/${item.name}/${productDir.name}/detail/${img}` );
const basePrice = faker.commerce.price({ min: 1000, max: 50000, dec: 0, }); const discount = faker.number.float({ min: 0.7, max: 0.95, fractionDigits: 2, }); const discountedPrice = Math.round(basePrice * discount);
const product = { id: faker.string.uuid(), name: productInfo.name || productDir.name, desc: productInfo.tip || faker.commerce.productDescription(), categoryId: categoryId, categoryName: productInfo.category_name || item.name, cover: coverImage, gallery: gallery, details: details, price: discountedPrice.toString(), originalPrice: basePrice.toString(), sales: faker.number.int({ min: 10, max: 9999 }), rating: faker.number.float({ min: 4.0, max: 5.0, fractionDigits: 1 }), stock: faker.number.int({ min: 0, max: 999 }), url: productInfo.url || "",
coverImage: coverImage, detailImages: details, tip: productInfo.tip, category_id: productInfo.category_id, tab_name: productInfo.tab_name, };
subCategoryProducts.push(product); products.push(product); }
subCategories.push({ id: `${categoryId}-${item.name}`.replace(/[^a-zA-Z0-9-]/g, "-"), name: item.name, products: subCategoryProducts, }); } }
return { subCategories, products }; };
module.exports = () => { const staticDataPath = path.join(__dirname, "mock-data.json"); const staticData = JSON.parse(fs.readFileSync(staticDataPath, "utf-8"));
const allProducts = []; const categories = staticData.categories.map((category) => { const categoryData = readCategoryData(category.id); allProducts.push(...categoryData.products);
return { ...category, banners: readCategoryBanners(category.id), subCategories: categoryData.subCategories, }; });
const data = { users: [], categories: categories, products: allProducts, banners: [ { id: "banner-001", imgUrl: "/images/carousel/carousel1.jpg", hrefUrl: "/category/new", }, { id: "banner-002", imgUrl: "/images/carousel/carousel2.jpg", hrefUrl: "/category/home-ac", }, { id: "banner-003", imgUrl: "/images/carousel/carousel3.jpg", hrefUrl: "/category/central-air-conditioner", }, ], };
for (let i = 1; i <= 20; i++) { const account = "3381292732@qq.com"; data.users.push({ id: faker.string.uuid(), account: account, password: "123456", accessToken: faker.string.uuid(), refreshToken: faker.string.uuid(), avatar: faker.image.avatar(), nickname: faker.person.firstName(), mobile: faker.phone.number({ style: "international" }), gender: faker.person.sex(), birthday: faker.date.past({ years: 30 }).toISOString().split("T")[0], cityCode: faker.location.zipCode(), provinceCode: faker.location.state({ abbreviated: true }), profession: faker.person.jobTitle(), }); }
return data; };
|
现在,我们的 db.json
结构已经更新。让我们来验证新的接口是否已经自动生成。
- 重新生成数据并启动服务: 在终端中运行
pnpm run mock
。premock
脚本会自动执行我们更新后的 generate-data.js
,生成新的 db.json
。 - 测试接口:
- 在
mock/db.json
的 products
数组中,随便复制一个商品的 id
值。 - 打开浏览器,在地址栏输入
http://localhost:3001/products/
并粘贴你复制的 ID。 - 预期结果: 浏览器中会显示该商品 ID 对应的 单个商品 的完整 JSON 对象。
我们通过一次精巧的脚本升级,成功地为 json-server
提供了它所需要的数据结构,从而“解锁”了 GET /products/:id
这个对商品详情页至关重要的接口。现在,后端已万事俱备,我们可以进入下一节,开始封装消费这个接口的前端逻辑了。
5.2 逻辑层先行:封装 useGoods
Composable
在构建 Detail
页面之前,我们先来打造它的“数据引擎”。我们将创建一个名为 useGoods
的 Composable,它的唯一职责就是:根据路由中的商品 ID,获取并提供该商品的详细数据及其加载状态。
这再一次实践了“关注点分离”的核心思想:让组件专注于“如何展示”,让 Composable 专注于“如何获取”。
当前任务: 5.2 - 封装 useGoods
Composable
任务目标: 创建 apis/detail.ts
来封装商品详情的 API 请求,并在 views/Detail/composables/
目录下创建 useGoods.ts
,利用 TanStack Query
将获取单个商品数据的逻辑封装起来。
1. 设计思路:模式的复用与演进
Composable 设计
开始封装 useGoods
架构师,我要开始封装获取商品详情的逻辑了。这个流程我感觉很熟悉了:它需要依赖路由 id
,需要用 TanStack Query
来管理异步状态… 这和我们上一章做的 useCategory
是不是几乎一模一样?
架构师
你已经完全掌握了这种模式的精髓!完全正确。useGoods
和 useCategory
在 结构和思想 上是完全一致的。它们都遵循“依赖路由参数的异步数据获取”这一通用模式。
架构师
细微的差别在于 具体的数据。useGoods
获取的是单个 Product
对象,而 useCategory
获取的是 TopCategory
对象。所以我们需要为它创建新的 API 函数和类型定义。但核心的、使用 computed
id 作为 queryKey
的 TanStack Query
模式,是完全可以复用的。
我明白了。这就是建立“最佳实践”的好处,遇到相似的问题,我们就可以用同样优雅的模式去解决。
2. 创建类型与 API
2.1 在 src/types/
目录下,创建一个新文件 detail.ts
为了保持类型文件的职责单一,我们将商品详情页相关的类型单独存放。我们将严格按照你提供的 JSON 数据结构来定义类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
export interface GoodsDetail { id: string; name: string; desc: string; categoryId: string; categoryName: string; cover: string; gallery: string[]; details: string[]; price: string; originalPrice: string; sales: number; rating: number; stock: number; url: string; }
|
- 类型精确匹配: 我们定义的
GoodsDetail
接口精确地反映了 GET /products/:id
接口返回的数据结构,包括 coverImage
, detailImages
, category_name
等所有字段。
2.2 创建 src/apis/detail.ts
这个文件将专门负责商品详情相关的 API 请求。
1 2 3 4 5 6 7 8 9 10 11 12 13
| import httpInstance from "@/utils/http"; import type { GoodsDetail } from "@/types/detail";
export const getDetailAPI = (id: string): Promise<GoodsDetail> => { return httpInstance.get(`/products/${id}`); };
|
3. 创建 useGoods
Composable
现在,我们来构建核心的 Composable。
首先,在 src/views/
目录下创建一个 Detail
文件夹,并在其中创建 composables
文件夹。然后,在 composables
文件夹内创建 useGoods.ts
文件。
1 2 3 4 5 6 7
| src/ └── views/ ├── Category/ └── Detail/ <-- 新建 ├── composables/ <-- 新建 │ └── useGoods.ts <-- 新建 └── index.vue <-- 后续创建
|
请在 src/views/Detail/composables/useGoods.ts
文件中写入以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import { computed } from 'vue' import { useRoute } from 'vue-router' import { useQuery } from '@tanstack/vue-query' import { getDetailAPI } from '@/apis/detail'
export function useGoods() { const route = useRoute(); const goodsId = computed(() => route.params.id as string);
const { data: goodsData, isLoading, isError } = useQuery({ queryKey: ['goodsDetail', goodsId],
queryFn: ({ queryKey }) => { const id = queryKey[1] as string; return getDetailAPI(id); },
enabled: computed(() => !!goodsId.value) });
return { goodsData, isLoading, isError } }
|
4. 代码解读
- 熟悉的模式: 看到这段代码,你应该会会心一笑。它的结构和
useCategory
几乎完全一样!我们定义了一个响应式的 goodsId
,将它作为 queryKey
的一部分,然后 TanStack Query
就会为我们处理好所有的数据获取、缓存和自动更新。 - 职责的再次确认: 这个 Composable 的职责非常纯粹:提供单个商品的详细数据及其状态。它不关心 UI 如何渲染,也不关心用户交互。
- 健壮性:
enabled: computed(() => !!goodsId.value)
再次确保了我们只在 URL 中确实存在商品 ID 时才发起请求。
我们已经成功地为商品详情页打造了一个坚固、可靠且独立的数据引擎。在下一节,我们将开始搭建详情页的视觉骨架,并看看消费这个 Composable 是多么简单。
5.3 编码实现:详情页骨架与图片放大镜
本节目标: 我们将分两步,完整地构建出商品详情页的核心视觉与交互。首先,我们将从零开始,利用 @vueuse/core
打造一个技术含量很高的 GoodsImage
图片放大镜组件。然后,我们将搭建 Detail.vue
页面的整体布局,并将我们刚刚完成的 GoodsImage
组件“安放”进去,最终完成一个带有加载状态的、结构清晰的页面骨架。
第一步:【核心】构建 GoodsImage
图片预览组件
这是本模块技术最密集的部分。我们将创建一个独立的、可复用的图片预览组件,它包含“缩略图点击切换”和“鼠标悬停放大”两大核心功能。
文件路径: src/views/Detail/components/GoodsImage.vue
任务目标: 创建一个独立的图片预览组件。学习如何使用 @vueuse/core
的 useMouseInElement
来响应式地获取鼠标位置,并通过 watch
监听和一系列计算,最终实现一个高性能的放大镜效果。
1. 逻辑层 (<script setup>
):交互的核心驱动
GoodsImage
组件的灵魂在于其 <script>
部分,这里处理了所有的状态和复杂的坐标计算。我们将分步进行讲解。
请在 src/views/Detail/components/
目录下创建 GoodsImage.vue
文件,并写入以下 <script setup>
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| <script lang="ts" setup> import { ref, watch, computed } from 'vue' import type { GoodsDetail } from '@/types/detail' import { useMouseInElement } from '@vueuse/core'
defineProps<{ products: GoodsDetail }>()
const activeIndex = ref(0) const switchImage = (i: number) => { activeIndex.value = i }
const target = ref(null) const { elementX, elementY, isOutside } = useMouseInElement(target)
const left = ref(0) const top = ref(0) const positionX = ref(0) const positionY = ref(0)
watch([elementX, elementY, isOutside], () => { if (isOutside.value) return
left.value = elementX.value - 100 top.value = elementY.value - 100 if (left.value < 0) left.value = 0 if (left.value > 200) left.value = 200 if (top.value < 0) top.value = 0 if (top.value > 200) top.value = 200
positionX.value = -left.value * 2 positionY.value = -top.value * 2 })
const showMagnifier = computed(() => { return !isOutside.value }) </script>
|
逻辑深度解读:
- 缩略图切换: 我们用一个
activeIndex
的 ref
来记录当前激活的缩略图索引。@mouseenter
事件会调用 switchImage
方法来更新这个索引,从而切换中图的 src
。 - 获取鼠标位置: 我们引入了
@vueuse/core
的 useMouseInElement
。把它应用到中图的 DOM 元素上 (ref="target"
),它就会为我们提供三个 响应式 的 ref:elementX
(鼠标相对元素左上角的 X 坐标)、elementY
(Y 坐标) 和 isOutside
(布尔值,表示鼠标是否在元素外)。 - 显示控制: 我们监听
isOutside
,当鼠标离开时,放大镜和滑块会隐藏。 - 滑块位置计算: 滑块的尺寸是
200x200
。为了让鼠标始终在滑块中心,我们将滑块的 left
设置为 鼠标X坐标 - 100
。边界控制确保滑块不会移出中图 400x400
的范围。 - 大图定位计算: 这是放大镜效果的核心。大图的尺寸是
800x800
,是中图的两倍(放大系数为 2)。当滑块向右移动 1px
时,大图的背景 (background-position
) 需要向 左 移动 2px
,从而在放大镜中“揭示”出右侧的内容。因此,positionX = -left * 2
。
2. 视图层 (<template>
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <template> <div class="product-image"> <div class="middle" ref="target"> <img draggable="false" :src="products.gallery[activeIndex]" alt="" /> <div class="layer" v-show="showMagnifier" :style="{ left: `${left}px`, top: `${top}px` }"></div> </div> <ul class="small"> <li v-for="(img, i) in products.gallery" :key="i" @mouseenter="switchImage(i)" :class="{ active: activeIndex === i }"> <img draggable="false" :src="img" alt="" /> </li> </ul> <div class="large" v-show="showMagnifier" :style="[{ backgroundImage: `url(${products.gallery[activeIndex]})`, backgroundPositionX: `${positionX}px`, backgroundPositionY: `${positionY}px`, }]"></div> </div> </template>
|
3. 样式层 (<style>
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| <style scoped lang="scss"> .product-image { width: 480px; height: 400px; position: relative; display: flex;
.middle { width: 400px; height: 400px; background: #f5f5f5; position: relative; cursor: move; } .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; background-size: 800px 800px; background-color: #f8f8f8; } .layer { width: 200px; height: 200px; background: rgba(0,0,0,0.2); 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 $GLColor; } } } } </style>
|
第二步:组装 Detail
页面骨架
现在我们最复杂的“零件”已经造好,组装主页面就变得非常简单了。
请在 src/views/Detail/
目录下创建 index.vue
文件,并写入以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
| <script setup lang="ts"> import { useGoods } from './composables/useGoods' import GoodsImage from './components/GoodsImage.vue'
const { goodsData, isLoading } = useGoods() </script>
<template> <div class="detail-page"> <div class="container" v-if="!isLoading && goodsData"> <el-breadcrumb class="detail-page__breadcrumb" separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: `/category/${goodsData.categoryId}` }"> {{ goodsData.categoryName }} </el-breadcrumb-item> <el-breadcrumb-item>{{ goodsData.name }}</el-breadcrumb-item> </el-breadcrumb>
<div class="detail-page__info-panel"> <el-row :gutter="20"> <el-col :span="12"> <GoodsImage :products="goodsData" /> </el-col> <el-col :span="12"> </el-col> </el-row> </div>
<div class="detail-page__detail-panel"> <h2>商品详情</h2> <div v-for="img in goodsData.details" :key="img"> <img :src="img" alt=""> </div> </div> </div> <div class="container loading-container" v-else> <el-skeleton style="width: 100%" animated> </el-skeleton> </div> </div> </template>
<style scoped lang='scss'> .detail-page { background: #f5f5f5; padding-top: 20px;
&__breadcrumb { padding: 20px 0; }
&__info-panel { background: #fff; padding: 30px; }
&__detail-panel { background: #fff; margin-top: 20px; padding: 30px;
&__title { font-size: 22px; font-weight: bold; margin-bottom: 20px; }
&__content { // 图片居中 display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; } } }
.info-panel { &__media { width: 580px; height: 600px; }
&__spec { flex: 1; } }
.spec { &__name { font-size: 22px; }
&__desc { color: #999; margin-top: 10px; }
&__service { margin-top: 20px;
.service-label { width: 50px; color: #999; } } }
.loading-container { background-color: #fff; min-height: 800px; } </style>
|
本节小结:
我们通过“自底向上”的策略,先集中精力攻克了技术最复杂的 GoodsImage
组件,掌握了 useMouseInElement
和 watch
监听的核心用法。然后,我们轻松地搭建了 Detail
页面的宏观布局,并将 GoodsImage
作为一个“黑盒”组件组装进去,完美地实践了组件化开发的思想。
5.4 【核心】从零到一:构建购物车完整流程
本章目标: 结束了商品详情的探索,我们现在进入交易流程的核心——购物车。本节,我们将从零开始,构建一个功能完整的购物车页面。核心是 深度实践 Pinia,您将学习如何设计一个包含 state
, getters
和 actions
的复杂 store
来管理购物车的完整状态。同时,我们将应用 Element Plus 的 ElTable
组件来优雅地展示和操作这份复杂的状态。
第一步:奠定基石 - 创建 cartStore
在构建任何 UI 之前,我们必须先打造它的“数据大脑”。购物车的所有状态——商品列表、选中状态、总价、总数——都应该由一个专门的 cartStore
来统一管理。
1. 准备工作:定义类型 (CartItem
)
我们首先为购物车中的商品项定义一个清晰的 TypeScript 类型接口。
文件路径: src/types/cart.ts
任务目标: 创建购物车项的 TypeScript 类型接口 CartItem
,严格使用 id
作为唯一标识。
请在 src/types/
目录下创建 cart.ts
文件,并写入以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
export interface CartItem { id: string; name: string; picture: string; price: string; count: number; selected: boolean; attrsText?: string; }
|
2. 创建 cartStore.ts
类型就绪后,我们来创建 store
文件本身。
文件路径: src/stores/cartStore.ts
任务目标: 创建 cartStore
,定义其 state
, getters
, 和 actions
,并开启持久化。
请在 src/stores/
目录下创建 cartStore.ts
文件,并写入以下完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
| import { defineStore } from "pinia"; import { ref, computed } from "vue"; import type { CartItem } from "@/types/cart"; import { useUserStore } from "./user";
export const useCartStore = defineStore("cart", () => { const userStore = useUserStore(); const userInfo = computed( () => userStore.userInfo as { accessToken?: string } );
const cartList = ref<CartItem[]>([]);
const addCart = (goods: CartItem) => { if (userInfo.value.accessToken) { const existingItem = cartList.value.find( (item: CartItem) => item.id === goods.id ); if (existingItem) { existingItem.count += goods.count; } else { cartList.value.push(goods); } ElMessage.success("添加成功"); } else { ElMessage.warning("请先登录"); } };
const delCart = (id: string) => { const index = cartList.value.findIndex((item: CartItem) => item.id === id); if (index > -1) { cartList.value.splice(index, 1); ElMessage.info("商品已删除"); } };
const updateCartSelected = (id: string, selected: boolean) => { const item = cartList.value.find((item: CartItem) => item.id === id); if (item) { item.selected = selected; } };
const updateAllCart = (selected: boolean) => { cartList.value.forEach((item: CartItem) => (item.selected = selected)); };
const clearCart = () => { cartList.value = []; };
const isAllSelected = computed( () => cartList.value.length > 0 && cartList.value.every((item: CartItem) => item.selected) );
const totalCount = computed(() => cartList.value.reduce((sum: number, item: CartItem) => sum + item.count, 0) );
const totalPrice = computed(() => cartList.value.reduce( (sum: number, item: CartItem) => sum + item.count * Number(item.price), 0 ) );
const selectedCount = computed(() => cartList.value .filter((item: CartItem) => item.selected) .reduce((sum: number, item: CartItem) => sum + item.count, 0) );
const selectedPrice = computed(() => cartList.value .filter((item: CartItem) => item.selected) .reduce((sum: number, item: CartItem) => sum + item.count * Number(item.price), 0) );
return { cartList, addCart, delCart, updateCartSelected, updateAllCart, clearCart, isAllSelected, totalCount, totalPrice, selectedCount, selectedPrice, }; });
|
第二步:用 ElTable
构建购物车页面 (CartPage.vue
)
Store 已经就绪,现在我们可以安心地构建视图了。
1. 创建页面和路由
- 在
src/views/
目录下创建 Cart
文件夹及 index.vue
文件。 - 在
src/router/index.ts
中添加购物车页面的路由规则:1 2 3 4 5 6 7
|
{ path: '/cart', component: () => import('@/views/Cart/index.vue') }
|
2. 完整代码实现
请在 src/views/Cart/index.vue
中写入以下完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183
| <script setup lang="ts"> import { useCartStore } from '@/stores/cartStore' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus'
const cartStore = useCartStore() const router = useRouter()
const handleSettlement = () => { if (cartStore.selectedCount === 0) { ElMessage.warning('请至少选择一件商品进行结算') return } router.push('/checkout') } </script>
<template> <div class="cart-page"> <div class="container"> <el-breadcrumb separator=">"> <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item> <el-breadcrumb-item>购物车</el-breadcrumb-item> </el-breadcrumb> <div class="cart-page__content"> <el-table :data="cartStore.cartList" class="cart-page__table" style="width: 100%"> <template #empty> <el-empty description="购物车中还没有商品"> <el-button type="primary" @click="router.push('/')">随便逛逛</el-button> </el-empty> </template>
<el-table-column width="60" align="center"> <template #header> <el-checkbox :model-value="cartStore.isAllSelected" @change="(selected) => cartStore.updateAllCart(selected as boolean)" /> </template> <template #default="{ row }"> <el-checkbox :model-value="row.selected" @change="(selected) => cartStore.updateCartSelected(row.id, selected as boolean)" /> </template> </el-table-column>
<el-table-column label="商品信息" width="400"> <template #default="{ row }"> <div class="goods-info"> <router-link :to="`/product/${row.id}`"><img :src="row.picture" :alt="row.name"></router-link> <div> <p class="name">{{ row.name }}</p> <p class="attr">{{ row.attrsText }}</p> </div> </div> </template> </el-table-column>
<el-table-column label="单价" width="220" align="center"> <template #default="{ row }"> <span class="price">¥{{ row.price }}</span> </template> </el-table-column>
<el-table-column label="数量" width="180" align="center"> <template #default="{ row }"> <el-input-number v-model="row.count" :min="1" /> </template> </el-table-column>
<el-table-column label="小计" width="180" align="center"> <template #default="{ row }"> <span class="price subtotal">¥{{ (Number(row.price) * row.count).toFixed(2) }}</span> </template> </el-table-column>
<el-table-column label="操作" width="140" align="center"> <template #default="{ row }"> <el-popconfirm title="确认删除吗?" @confirm="cartStore.delCart(row.id)"> <template #reference> <a href="javascript:;" class="action-link">删除</a> </template> </el-popconfirm> </template> </el-table-column> </el-table> </div>
<div class="cart-page__action-bar"> <div class="batch-info"> 共 {{ cartStore.totalCount }} 件商品,已选择 {{ cartStore.selectedCount }} 件,商品合计: <span class="total-price">¥ {{ cartStore.selectedPrice.toFixed(2) }} </span> </div> <div class="buttons"> <el-button size="large" type="primary" @click="handleSettlement">下单结算</el-button> </div> </div> </div> </div> </template>
<style lang="scss" scoped> .cart-page { margin-top: 20px;
.container { max-width: 1240px; margin: 0 auto; }
&__content { background: #fff; margin-top: 20px; }
&__table { .goods-info { display: flex; align-items: center;
img { width: 100px; height: 100px; }
div { width: 280px; font-size: 16px; padding-left: 10px;
.name { line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.attr { font-size: 14px; color: #999; } } }
.price { font-size: 16px; color: $priceColor; }
.subtotal { font-weight: bold; }
.action-link { color: $GLColor;
&:hover { color: lighten($GLColor, 10%); } } }
&__action-bar { display: flex; background: #fff; margin-top: 20px; height: 80px; align-items: center; font-size: 16px; justify-content: space-between; padding: 0 30px;
.batch-info { color: #666; }
.total-price { font-size: 18px; margin-right: 20px; font-weight: bold; color: $priceColor; } } } </style>
|
5.5 集成购物车功能,完成交互闭环
本节目标: 我们将把 cartStore
的能力注入到两个关键组件中:商品详情页 (Detail.vue
) 和 全局顶栏 (LayoutHeader.vue
)。您将学习如何在一个组件中调用 store
的 action
来修改状态(添加商品),并在另一个组件中响应式地展示 store
的 state
和 getters
(更新购物车图标数量),从而深度理解 Pinia 作为“单一事实来源”在跨组件通信中的核心作用。
第一步:在商品详情页实现“加入购物车”
这是将商品送入购物车的唯一入口,也是连接“商品浏览”和“商品购买”两大流程的关键桥梁。
文件路径: src/views/Detail/index.vue
任务目标: 在商品详情页的右侧信息区,添加数量选择器 (ElInputNumber
) 和“加入购物车”按钮,并为其绑定事件,调用 cartStore
中的 addCart
action。
1. 更新 <script setup>
我们需要引入 cartStore
,并创建一个 ref
来绑定数量选择器的值,最后编写一个事件处理函数来执行添加操作。
请打开 src/views/Detail/index.vue
并更新其 <script setup>
部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <script setup lang="ts"> import { ref } from 'vue' import { useGoods } from './composables/useGoods' import GoodsImage from './components/GoodsImage.vue' import { useCartStore } from '@/stores/cartStore'
const { goodsData, isLoading } = useGoods() const cartStore = useCartStore()
const count = ref(1)
const addToCart = () => { if (goodsData.value) { cartStore.addCart({ id: goodsData.value.id, name: goodsData.value.name, picture: goodsData.value.cover, price: goodsData.value.price, count: count.value, selected: true, attrsText: '', skuId: '', }) } } </script>
|
代码解读:
- 我们引入并实例化了
cartStore
,这样在本组件内就可以随时访问它的 state 和 actions。 - 我们创建了
count
ref,它将与 ElInputNumber
组件进行 v-model
双向绑定。 addToCart
函数是核心。它会构建一个符合 CartItem
类型的对象,然后把它作为参数传递给 cartStore.addCart
action。所有复杂的添加逻辑(判断商品是否存在、更新数量等)都在 store
内部完成,组件本身只负责“通知”store
。
2. 更新 <template>
现在,我们在右侧信息栏 (<el-col :span="12">
) 中,添加数量选择器和按钮。
请更新 src/views/Detail/index.vue
的 <template>
部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <template> <div class="detail-page"> <div class="container" v-if="!isLoading && goodsData"> <div class="detail-page__info-panel"> <el-row :gutter="20"> <el-col :span="12"> <GoodsImage :products="goodsData" /> </el-col> <el-col :span="12"> <div class="info-panel__spec"> <h2 class="spec__name">{{ goodsData.name }}</h2> <p class="spec__desc">{{ goodsData.desc }}</p> <p class="spec__price">¥{{ goodsData.price }}</p> <div class="spec__quantity"> <span class="quantity-label">数量</span> <el-input-number v-model="count" :min="1" /> </div>
<div class="spec__actions"> <el-button size="large" type="primary" @click="addToCart">加入购物车</el-button> </div> </div> </el-col> </el-row> </div> </div> </div> </template>
|
3. 添加样式
最后,为我们新增的元素添加一些样式。
请在 src/views/Detail/index.vue
的 <style>
块中补充以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| .spec { &__price { font-size: 22px; color: $priceColor; margin-top: 10px; }
&__quantity { display: flex; align-items: center; margin-top: 20px; .quantity-label { width: 50px; color: #999; } }
&__actions { margin-top: 30px; } }
|
第二步:在全局顶栏添加入口点
用户需要一个在任何页面都能看到自己购物车状态、并能快速进入购物车页面的入口。全局顶栏 (LayoutHeader.vue
) 是放置这个入口的最佳位置。
文件路径: src/views/Layout/components/LayoutHeader.vue
任务目标: 在顶栏右侧添加一个购物车图标,并利用 cartStore
的 getter
实时显示购物车中的商品总数。点击该图标可以跳转到购物车页面。
请打开 src/views/Layout/components/LayoutHeader.vue
并进行如下修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| <script setup lang="ts"> import { useCategoryStore } from '@/stores/categoryStore' import { useCartStore } from '@/stores/cartStore'; import { onMounted } from 'vue'
const categoryStore = useCategoryStore() const cartStore = useCartStore();
onMounted(() => { categoryStore.getCategory() }) </script>
<template> <header class="app-header"> <div class="container"> <ul class="app-header__nav"> </ul> <div class="app-header__actions"> <div class="app-header__cart"> <router-link to="/cart"> <i-ep-shopping-cart /> <em>{{ cartStore.totalCount }}</em> </router-link> </div>
</div> </div> </header> </template>
<style lang="scss" scoped>
.app-header__actions { display: flex; align-items: center; }
.app-header__cart { margin-left: 20px; a { position: relative; display: block; font-size: 22px; color: #666; em { position: absolute; top: -5px; right: -12px; height: 16px; line-height: 1; min-width: 16px; text-align: center; border-radius: 8px; background: $priceColor; color: #fff; font-size: 12px; font-style: normal; padding: 2px 4px; } } } </style>
|
响应式更新的魔力:
我们仅仅是在模板中使用了 cartStore.totalCount
这个 getter
。由于 Pinia 的 getters
本质上是 computed
属性,它们是完全响应式的。现在,当你在商品详情页调用 cartStore.addCart()
导致 cartList
状态变化时,totalCount
getter
会自动重新计算,并 立即将新值反映到 LayoutHeader.vue
的角标上。这就是 Pinia 作为单一事实来源带来的巨大便利。
5.6 模块提交与总结
至此,我们已经成功地完成了 vue3-webShop
项目的第三个核心业务模块——商品详情与购物车。
我们不仅构建了两个关键页面,更重要的是,我们深入实践了现代前端应用中最核心的两种状态管理模式:
- 服务端状态 (
TanStack Query
): 通过封装 useGoods
Composable,我们再次巩固了如何优雅地处理与服务器相关的异步数据。 - 客户端状态 (
Pinia
): 通过从零设计和实现 cartStore
,我们深度掌握了如何管理纯粹的、复杂的、需要全局共享的客户端交互状态。
现在,是时候将我们本模块的成果作为一个重要的里程碑,提交到版本库了。
当前任务: 5.6 - 模块成果提交
任务目标: 将模块五中完成的商品详情页与购物车核心功能,作为一个完整的特性提交到 Git 仓库。
命令行操作
打开您的终端,确保位于项目根目录下,然后执行以下命令:
将所有已修改和新建的文件添加到 Git 暂存区:
提交代码,并附上符合“约定式提交”规范的 message:
1
| git commit -m "feat(detail, cart): build product detail page and Pinia-driven cart"
|
Commit Message 解读:
feat
: 表示这是一个新功能 (feature) 的提交。(detail, cart)
: 指明了本次提交影响的主要范围是“商品详情”和“购物车”两大模块。build product detail page and Pinia-driven cart
: 简明扼要地描述了我们完成的具体工作:构建了商品详情页和一个由 Pinia 驱动的购物车。