模块五:交互深化 - 构建商品详情页与购物车

模块五:交互深化 - 构建商品详情页与购物车


本模块任务清单

任务模块任务名称核心技术应用与学习要点
后端与数据准备利用现有 products 接口json-server: 无需修改!直接利用 json-server 自动生成的 GET /products/:id 接口来获取商品详情。
逻辑层先行封装 useGoods ComposableTanStack Query, TypeScript: 创建 useGoods Composable 获取商品详情数据,并定义相应的类型接口。
页面骨架搭建搭建商品详情页 (Detail) 静态布局Vue Router, BEM: 搭建页面基础结构,包括商品信息区、详情展示区和热销推荐区。
原子组件:图片交互实现 GoodsImage 组件@vueuse/core: 基于 useMouseInElement,构建一个高性能的图片预览及放大镜组件。
【核心】客户端状态第一步:创建 cartStorePinia, TypeScript: 创建 cartStore.ts,定义 state、getters (总数、总价) 和 actions (添加、删除等) 的基本结构和类型。
【核心】客户端状态第二步:实现“加入购物车”Pinia, Element Plus: 在 Detail.vue 中,实现数量选择、点击按钮调用 cartStoreaddCart 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
// mock/generate-data.cjs
const { faker } = require("@faker-js/faker");
const fs = require("fs");
const path = require("path");

// 简单的Banner读取函数
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);

// 获取封面图片(优先使用cover文件夹中的第一张图片)
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]}`
: "";

// 构建gallery数组
const gallery = galleryImages.map(
(img) =>
`/data/${categoryId}/${item.name}/${productDir.name}/gallery/${img}`
);

// 构建details数组
const details = detailImages.map(
(img) =>
`/data/${categoryId}/${item.name}/${productDir.name}/detail/${img}`
);

// 使用faker生成模拟数据
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 = {
// GoodsDetail接口要求的字段
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); // 添加到顶层products数组
}

subCategories.push({
id: `${categoryId}-${item.name}`.replace(/[^a-zA-Z0-9-]/g, "-"),
name: item.name,
products: subCategoryProducts,
});
}
}

return { subCategories, products };
};

module.exports = () => {
// 1. 读取静态的 mock-data.json 作为基础
const staticDataPath = path.join(__dirname, "mock-data.json");
const staticData = JSON.parse(fs.readFileSync(staticDataPath, "utf-8"));

// 2. 为每个分类添加 banners 和 subCategories,同时收集所有products
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, // 添加顶层products数组
// 全局 banners 保持不变,用于首页等场景
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",
},
],
};

// 3. 生成模拟用户数据 (逻辑不变)
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 结构已经更新。让我们来验证新的接口是否已经自动生成。

  1. 重新生成数据并启动服务: 在终端中运行 pnpm run mockpremock 脚本会自动执行我们更新后的 generate-data.js,生成新的 db.json
  2. 测试接口:
    • mock/db.jsonproducts 数组中,随便复制一个商品的 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 是不是几乎一模一样?

架构师

你已经完全掌握了这种模式的精髓!完全正确。useGoodsuseCategory结构和思想 上是完全一致的。它们都遵循“依赖路由参数的异步数据获取”这一通用模式。

那有什么不同吗?

架构师

细微的差别在于 具体的数据useGoods 获取的是单个 Product 对象,而 useCategory 获取的是 TopCategory 对象。所以我们需要为它创建新的 API 函数和类型定义。但核心的、使用 computed id 作为 queryKeyTanStack 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
// src/types/detail.ts

// 商品详情数据类型
// 根据实际数据结构定义接口
export interface GoodsDetail {
/** 商品唯一标识符 */
id: string;
/** 商品名称 */
name: string;
/** 商品描述 */
desc: string;
/** 分类 ID */
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
// src/apis/detail.ts
import httpInstance from "@/utils/http";
import type { GoodsDetail } from "@/types/detail";

/**
* @description: 获取商品详情
* @param {String} id - 商品 ID
*/
export const getDetailAPI = (id: string): Promise<GoodsDetail> => {
// 直接请求由 json-server 自动生成的 RESTful 接口
// 我们的 httpInstance 响应拦截器会自动剥离 .data
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
// src/views/Detail/composables/useGoods.ts
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 格式: ['业务模块', 响应式参数]
queryKey: ['goodsDetail', goodsId],

queryFn: ({ queryKey }) => {
// 从 queryKey 中解构出 ID
const id = queryKey[1] as string;
return getDetailAPI(id);
},

// 只有在 goodsId.value 存在时,才执行查询
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/coreuseMouseInElement 来响应式地获取鼠标位置,并通过 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
}>()

// 1. 缩略图切换逻辑
const activeIndex = ref(0)
const switchImage = (i: number) => {
activeIndex.value = i
}

// 2. 放大镜效果逻辑
const target = ref(null) // 获取 DOM 元素
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], () => {
// 3. 控制滑块和放大镜的显示与隐藏
if (isOutside.value) return

// 4. 计算滑块的位置 (left, top)
// 有效移动范围:水平 0-200px, 垂直 0-200px
// left 的计算:鼠标在盒子内的 X 坐标 - 滑块宽度的一半
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

// 5. 计算大图的背景定位 (positionX, positionY)
// 移动方向和滑块相反,且需要乘以放大倍数
positionX.value = -left.value * 2
positionY.value = -top.value * 2
})

// 6. 计算属性,用于控制放大镜的显示/隐藏
const showMagnifier = computed(() => {
return !isOutside.value
})
</script>

逻辑深度解读:

  1. 缩略图切换: 我们用一个 activeIndexref 来记录当前激活的缩略图索引。@mouseenter 事件会调用 switchImage 方法来更新这个索引,从而切换中图的 src
  2. 获取鼠标位置: 我们引入了 @vueuse/coreuseMouseInElement。把它应用到中图的 DOM 元素上 (ref="target"),它就会为我们提供三个 响应式 的 ref:elementX (鼠标相对元素左上角的 X 坐标)、elementY (Y 坐标) 和 isOutside (布尔值,表示鼠标是否在元素外)。
  3. 显示控制: 我们监听 isOutside,当鼠标离开时,放大镜和滑块会隐藏。
  4. 滑块位置计算: 滑块的尺寸是 200x200。为了让鼠标始终在滑块中心,我们将滑块的 left 设置为 鼠标X坐标 - 100。边界控制确保滑块不会移出中图 400x400 的范围。
  5. 大图定位计算: 这是放大镜效果的核心。大图的尺寸是 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'

// 调用我们之前创建的 composable 获取数据
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 组件,掌握了 useMouseInElementwatch 监听的核心用法。然后,我们轻松地搭建了 Detail 页面的宏观布局,并将 GoodsImage 作为一个“黑盒”组件组装进去,完美地实践了组件化开发的思想。


5.4 【核心】从零到一:构建购物车完整流程

本章目标: 结束了商品详情的探索,我们现在进入交易流程的核心——购物车。本节,我们将从零开始,构建一个功能完整的购物车页面。核心是 深度实践 Pinia,您将学习如何设计一个包含 state, gettersactions 的复杂 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
// src/types/cart.ts

/**
* 购物车商品项的类型接口
*/
export interface CartItem {
id: string; // 商品 ID,作为唯一标识
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
// src/stores/cartStore.ts
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();
// Pinia store 中 state 的最佳实践是使用 ref
const userInfo = computed(
() => userStore.userInfo as { accessToken?: string }
);

// State: 购物车列表
const cartList = ref<CartItem[]>([]);

// Action: 添加商品到购物车
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("请先登录");
}
};

// Action: 从购物车删除商品
const delCart = (id: string) => {
const index = cartList.value.findIndex((item: CartItem) => item.id === id);
if (index > -1) {
cartList.value.splice(index, 1);
ElMessage.info("商品已删除");
}
};

// Action: 更新单个商品的选中状态
const updateCartSelected = (id: string, selected: boolean) => {
const item = cartList.value.find((item: CartItem) => item.id === id);
if (item) {
item.selected = selected;
}
};

// Action: 更新所有商品的选中状态
const updateAllCart = (selected: boolean) => {
cartList.value.forEach((item: CartItem) => (item.selected = selected));
};

// Action: 清空购物车
const clearCart = () => {
cartList.value = [];
};

// Getter: 是否全选
const isAllSelected = computed(
() =>
cartList.value.length > 0 && cartList.value.every((item: CartItem) => item.selected)
);

// Getter: 商品总数
const totalCount = computed(() =>
cartList.value.reduce((sum: number, item: CartItem) => sum + item.count, 0)
);

// Getter: 商品总价
const totalPrice = computed(() =>
cartList.value.reduce(
(sum: number, item: CartItem) => sum + item.count * Number(item.price),
0
)
);

// Getter: 已选商品数量
const selectedCount = computed(() =>
cartList.value
.filter((item: CartItem) => item.selected)
.reduce((sum: number, item: CartItem) => sum + item.count, 0)
);

// Getter: 已选商品总价
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. 创建页面和路由

  1. src/views/ 目录下创建 Cart 文件夹及 index.vue 文件。
  2. src/router/index.ts 中添加购物车页面的路由规则:
    1
    2
    3
    4
    5
    6
    7
    // src/router/index.ts
    // ...
    {
    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">&yen;{{ 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">&yen;{{ (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">&yen; {{ 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)。您将学习如何在一个组件中调用 storeaction 来修改状态(添加商品),并在另一个组件中响应式地展示 storestategetters(更新购物车图标数量),从而深度理解 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' // 引入 ref
import { useGoods } from './composables/useGoods'
import GoodsImage from './components/GoodsImage.vue'
import { useCartStore } from '@/stores/cartStore' // 1. 引入 cartStore

const { goodsData, isLoading } = useGoods()
const cartStore = useCartStore() // 2. 获取 store 实例

const count = ref(1) // 3. 绑定商品数量

// 4. “加入购物车”事件处理函数
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, // 默认选中
// 注意:由于我们还未实现 SKU 功能,attrsText 和 skuId 暂时留空或不传
attrsText: '',
skuId: '', // 暂时使用商品 id 或一个临时值
})
}
}
</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">&yen;{{ 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
/* src/views/Detail/index.vue */
.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
任务目标: 在顶栏右侧添加一个购物车图标,并利用 cartStoregetter 实时显示购物车中的商品总数。点击该图标可以跳转到购物车页面。

请打开 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'; // 1. 引入 cartStore
import { onMounted } from 'vue'

const categoryStore = useCategoryStore()
const cartStore = useCartStore(); // 2. 获取实例

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 仓库。

命令行操作

打开您的终端,确保位于项目根目录下,然后执行以下命令:

  1. 将所有已修改和新建的文件添加到 Git 暂存区:

    1
    git add .
  2. 提交代码,并附上符合“约定式提交”规范的 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 驱动的购物车。