模块二:通用布局与首页开发

模块二:通用布局与首页开发

本模块任务清单

产品经理 Amy 走到了我们的工位前:“项目的第一阶段目标很明确:我们要先搭建起整个应用的‘骨架’——也就是通用的头部和底部,然后集中精力打造一个能立刻吸引用户眼球的首页。”
UI 设计师 Leo 紧接着在 Figma 中展示了他的最终设计稿:
“这是首页的视觉稿,包含了响应式的导航栏、全屏的轮播图,以及一个非对称布局的人气推荐板块。所有组件的间距、颜色和字体都已经标注好了。”
现在,需求已经明确,设计稿也已就绪。作为前端开发者,我们的任务就是将这些静态的设计稿,转化为一个动态的、数据驱动的、交互丰富的真实网页。

本模块将从零开始,完成整个应用的通用布局框架(导航、头部、底部),并开发功能丰富、数据驱动的电商首页。在本模块中,我们将直接应用 Element Plus 核心组件来高效构建 UI,首次深度实践 Pinia 进行全局状态管理,并使用 TanStack Query 以现代化的方式获取首页业务数据。

image-20250911090501818

img

  1. 任务 2.1: 静态布局骨架搭建
  2. 任务 2.2: 静态顶部通栏 (LayoutNav) 开发
  3. 任务 2.3: Pinia 实战 - 动态化顶部通栏
  4. 任务 2.4: 静态站点头部 (LayoutHeader) 开发
  5. 任务 2.5: Pinia 实战 - 动态渲染头部导航
  6. 任务 2.6: 静态站点底部 (LayoutFooter) 开发
  7. 任务 2.7: TanStack Query 实战 - 首页轮播图 (HomeBanner) 开发
  8. 任务 2.8: 首页-人气推荐 (HomeHotProduct) 板块开发

2.1 静态布局骨架搭建

一个大型应用的许多页面都共享着相同的外部框架,例如页头、页脚等。我们将这些公共部分抽离成一个 Layout 组件,其他页面作为其子路由嵌套在其中。这遵循了 DRY (Don't Repeat Yourself) 原则,是组件化开发的核心思想。

当前任务: 2.1 - 静态布局骨架搭建
文件路径: src/views/Layout/index.vue
任务目标: 利用 Element Plus 的布局容器组件,开发 Layout 主组件,并为顶部导航、头部、内容区和底部规划好挂载点。

2.1.1 设计思路:组件化与语义化

我们在【模块一】的路由配置中,已经将 / 路径指向了 Layout 组件,并将 Home 等页面作为其子路由。现在,我们的任务就是构建 Layout 这个父级容器。

通过分析设计图,我们可以认为 Layout/index.vue 的职责是组合 LayoutNavLayoutHeaderLayoutFooter<RouterView />。为了让这个组合的结构更加清晰和专业,我们将使用 Element Plus 提供的布局容器组件:

  • <el-container>: 外层容器。
  • <el-header>: 顶部容器,我们将在这里放置 LayoutNavLayoutHeader
  • <el-main>: 主要区域容器,用于包裹 <RouterView />,这是所有子路由组件将被渲染的地方。
  • <el-footer>: 底部容器,用于放置 LayoutFooter

使用这些语义化的标签,能让代码的可读性和可维护性大大增强。

2.1.2 编码实现

首先,我们需要在 src/views/Layout/ 目录下创建 index.vue 文件,以及其子组件目录 components/ 和其中的三个文件 LayoutNav.vue, LayoutHeader.vue, LayoutFooter.vue。(为保证流程,子组件暂时留空即可)

现在,我们来编写 src/views/Layout/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
<script setup>
import LayoutNav from './components/LayoutNav.vue'
import LayoutHeader from './components/LayoutHeader.vue'
import LayoutFooter from './components/LayoutFooter.vue'
</script>

<template>
<el-container>
<el-header>
<LayoutNav />
<LayoutHeader />
</el-header>
<el-main>
<RouterView />
</el-main>
<el-footer>
<LayoutFooter />
</el-footer>
</el-container>
</template>

<style lang="scss" scoped>
// 为 el-main 设置最小高度,确保页脚在内容不足时也能置底
.el-main {
min-height: calc(100vh - 281px); // 281px 是页头和页脚的大致总高度
}

// 移除 el-header 和 el-footer 的默认 padding
.el-header,
.el-footer {
padding: 0;
height: auto;
}
</style>

我们如何能在不修改 App.vue 的情况下,看到我们开发的 LayoutNav 组件?这需要我们理解 Vue Router 的核心工作流:

  1. 应用入口 (main.js): 我们的应用从 main.js 启动,在这一步,我们 createApp(App)app.use(router)。这使得整个应用具备了路由能力。
  2. 根组件 (App.vue): App.vue 是所有视图的根容器,它的模板中只有一个核心内容:<RouterView />。这是一个占位符,告诉 Vue Router:“所有匹配到的路由组件都在这里渲染”。
  3. 路由配置 (router/index.js): 我们的路由表 routes 数组中,配置了 path: '/' 对应的组件是 Layout 组件。
  4. 布局组件 (Layout/index.vue): 当我们访问根路径 / 时,Layout 组件就会被渲染到 App.vue<RouterView /> 中。而 Layout 组件内部又包含了 LayoutNav 组件。

结论: main.js -> App.vue -> RouterView -> (URL: '/') -> Layout.vue -> LayoutNav.vue。正是这条清晰的渲染链路,保证了我们接下来开发的每一个 Layout 子组件,都能够 在访问首页时被立刻看到

文件路径: src/app.vue

1
2
3
4
5
6
7
<script setup lang="ts">

</script>

<template>
<router-view />
</template>

2.2 静态顶部通栏 (LayoutNav) 开发

顶部通栏是位于页面最顶部的导航区域,通常包含用户的登录状态、快捷链接等。我们首先来开发它的静态结构和样式,即组件的“骨架”与“皮肤”。

当前任务: 2.2 - 静态顶部通栏 (LayoutNav) 开发
文件路径: src/views/Layout/components/LayoutNav.vue
任务目标: 开发一个纯静态的顶部通栏,包含“登录/注册”和“会员中心/退出登录”两种状态下的链接,并为其编写 SCSS 样式。

2.2.1 设计思路:状态分离

一个健壮的组件应该能够清晰地展示其不同状态下的视图。对于顶部通栏,核心的状态有两个:登录状态未登录状态

在这一步,我们先不关心状态如何切换,而是把两种状态下的 DOM 结构都完整地构建出来。我们将使用 <template> 标签来包裹这两种不同的视图,为下一步使用 v-if/v-else 进行动态切换打下基础。

2.2.2 视图层 (<template>) 实现

我们来分析并编写 src/views/Layout/components/LayoutNav.vue 的代码。为了在开发阶段能清晰地看到登录后的效果,我们暂时将 v-if 的条件硬编码为 true,有关于类名,我们严格遵守 BEM 规范

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
<template>
<nav class="app-topnav">
<div class="container">
<ul class="app-topnav__list">
<template v-if="true">
<li class="app-topnav__item"><a href="javascript:;" class="app-topnav__link"><i-ep-user
app-topnav__icon></i-ep-user>用户</a></li>
<li class="app-topnav__item">
<el-popconfirm title="确认退出吗?" @confirm="handleLogout" confirm-button-text="确认" cancel-button-text="取消">
<template #reference>
<a href="javascript:;" class="app-topnav__link">退出登录</a>
</template>
</el-popconfirm>
</li>
<li class="app-topnav__item"><router-link to="/member/order" class="app-topnav__link">我的订单</router-link></li>
<li class="app-topnav__item"><router-link to="/member" class="app-topnav__link">会员中心</router-link></li>
</template>

<template v-else>
<li class="app-topnav__item">
<a href="javascript:;" class="app-topnav__link">请先登录</a>
</li>
<li class="app-topnav__item"><a href="javascript:;" class="app-topnav__link">帮助中心</a></li>
<li class="app-topnav__item"><a href="javascript:;" class="app-topnav__link">关于我们</a></li>
</template>
</ul>
</div>
</nav>
</template>

代码解读:

  • 我们使用了 <ul><li> 构建了一个标准的导航列表。
  • <el-popconfirm> 是 Element Plus 提供的气泡确认框组件,我们用它来包裹“退出登录”链接,在用户点击时提供二次确认,这是一个非常好的用户体验实践。#reference 是一个插槽,用于指定触发弹框的元素。

2.2.3 样式层 (<style>) 实现

接下来,我们为这个组件编写 SCSS 样式。这里,我们将首次使用在【模块一】中定义的全局颜色变量。

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
<style lang="scss" scoped>
.app-topnav {
background: #333;

&__list {
display: flex;
height: 53px;
justify-content: flex-end;
align-items: center;
}

&__item {
// ~ 选择器:选择所有在当前元素之后的同级元素
~ .app-topnav__item {
.app-topnav__link {
border-left: 2px solid #666;
}
}
}

&__link {
padding: 0 15px;
color: #cdcdcd;
line-height: 1;
display: inline-block;

&:hover {
// 此处使用了我们在 var.scss 中定义的全局品牌色变量
color: $GLColor;
}
}

&__icon {
font-size: 14px;
margin-right: 2px;
}
}
</style>

代码解读:

  • &:hover { color: $GLColor; }: 注意,这里的 $GLColor 并非 CSS 的原生语法。它之所以能生效,是因为我们在【模块一】的 vite.config.js 中,通过 additionalData 配置,将 src/styles/var.scss 文件自动注入到了每一个 SCSS 文件中。这使得 $GLColor 成为了一个我们可以在项目中任何地方直接使用的全局变量。

2.3 Pinia 实战 - 动态化顶部通栏

现在,我们将为静态的顶部通栏注入真正的动态能力。我们将 从零开始,以前后端完整联动的专业视角,分步骤创建 可编程的模拟 API、配置开发代理、建立前端请求层、类型定义和 Pinia Store,最终实现由真实的模拟数据驱动的视图动态切换。

当前任务: 2.3 - Pinia 实战 - 动态化顶部通栏
任务目标: 搭建一个可处理自定义逻辑的 json-server,配置 Vite 代理解决跨域问题,并建立一个类型安全的“API -> Store -> Component”数据流,实现完整的动态化和退出登录功能。

2.3.1 搭建专业级模拟后端

简单的 db.json 无法模拟如 POST /login 这样的非 RESTful 接口。为此,我们将 json-server 作为一个 Node.js 模块,在 Express 服务中赋予其无限的扩展能力。

开发者日记
开发中

架构师,我们要模拟登录接口,但 POST /login 并不符合 json-server 默认的 RESTful 规则。这该怎么办?

架构师

问得好。这正是我们要从“声明式配置”走向“编程式扩展”的原因。我们将创建一个 server.cjs 文件,把 json-server 当作一个 Express 中间件来使用。这样,我们就能在 json-server 处理请求之前,用我们自己的代码“拦截”并处理特定路由,比如 /login

也就是说,我们可以为 /login 单独写一个处理函数,手动验证用户名密码,然后返回 json-server 数据库里对应用户的数据?

架构师

完全正确!这就是 json-server 的终极用法——将它的便捷性和 Express 的灵活性完美结合。同时,我们还会用 @faker-js/faker 来动态生成更逼真的用户数据。

1. 安装核心依赖

1
pnpm add -D json-server@0.17.4 @faker-js/faker

2. 创建模拟数据生成器 (mock/generate-data.cjs)
在项目根目录创建 mock 文件夹,并在其中新建 generate-data.cjs 文件。

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
// mock/generate-data.cjs
const { faker } = require("@faker-js/faker");

// 使用 CommonJS 语法 (.cjs),因为这是一个在 Node 环境中直接运行的脚本
module.exports = () => {
const data = {
// 资源必须是复数形式,如 users
users: [],
};

// 2. 根据 OpenAPI 规范,创建 20 个随机模拟用户
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(), // 修正字段名:token -> accessToken
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;
};

3. 创建可编程的服务器 (mock/server.cjs)
mock 文件夹中,创建 server.cjs 文件。

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
// mock/server.cjs
const jsonServer = require("json-server");
const generateData = require("./generate-data.cjs");

const server = jsonServer.create();
const router = jsonServer.router(generateData()); // 使用动态生成的数据
const middlewares = jsonServer.defaults();

server.use(middlewares);
server.use(jsonServer.bodyParser); // 必须启用请求体解析器,才能获取 POST 的 body

// 自定义 /login 路由,模拟登录逻辑
server.post("/login", (req, res) => {
const { account, password } = req.body;
const db = router.db; // 获取 lowdb 实例

const user = db.get("users").find({ account, password }).value();

if (user) {
// 登录成功,返回符合项目 API 规范的成功结构
res.status(200).json({
code: "1", // 通常用 '1' 或 '200' 表示成功
msg: "登录成功",
result: user,
});
} else {
// 登录失败,返回符合项目 API 规范的失败结构
res.status(401).json({
code: "0", // 通常用 '0' 或其他错误码表示失败
msg: "用户名或密码错误",
result: null
});
}
});

// 将所有其他请求(如 GET /users)交给 json-server 的默认路由处理
server.use(router);

const PORT = 3001;
server.listen(PORT, () => {
console.log(`JSON Server is running on http://localhost:${PORT}`);
});

4. 更新 package.json 启动脚本

1
2
3
4
5
6
// package.json
"scripts": {
"dev": "vite",
"mock": "node mock/server.cjs", // 添加或更新 mock 命令
// ...
}

5. 启动模拟服务器
打开 一个新的终端窗口,运行 pnpm run mock。我们的专业级模拟后端现在已经启动。

2.3.2 关键一步:配置 Vite 代理

现在,前端(localhost:5173)和模拟后端(localhost:3001)运行在不同的端口上,直接通信会遇到浏览器的 跨域(CORS) 限制。最佳解决方案是在开发环境中使用 Vite 内置的代理功能。

开发者日记
开发中

架构师,我在前端用 Axios 请求 http://localhost:3001/login,浏览器报了 CORS 错误!

架构师

经典的跨域问题。永远不要在前端代码里写死后端的具体地址和端口。我们应该利用开发服务器的代理功能。

怎么做呢?

架构师

我们在 vite.config.ts 里配置一个代理规则。比如,让所有以 /api 开头的请求,都由 Vite 服务器自动转发给 http://localhost:3001。前端请求时只需要写 /api/login,Vite 会在背后帮你完成“跨域”请求,浏览器对此毫不知情。这样既解决了跨域,也让前端代码更干净,将来部署时也无需修改。

1. 配置 vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// vite.config.ts
import { defineConfig } from 'vite'
// ... 其他 import

export default defineConfig({
// ... 其他配置
server: {
proxy: {
// 关键配置:创建一个代理
'/api': { // 匹配所有以 '/api' 开头的请求
target: 'http://localhost:3001', // 代理的目标地址
changeOrigin: true, // 必须开启,改变请求头的 origin
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 '/api' 前缀
},
},
},
// ... 其他配置
})

代码解读:

  • '/api': 这是一个标识。告诉 Vite,任何看起来像 http://localhost:5173/api/xxx 的请求都需要被代理。
  • target: 代理要转发到的真实后端地址。
  • changeOrigin: true: 这是必选项,它会将请求头中的 Origin 字段修改为 target 的地址,以欺骗后端服务器,解决跨域问题。
  • rewrite: 前端为了触发代理,请求了 /api/login,但我们的后端接口实际上是 /loginrewrite 的作用就是在转发前,把路径中的 /api 前缀去掉。

2. 更新 HTTP 请求基地址
为了让所有 API 请求都自动带上 /api 前缀,我们需要配置 axios 实例。

1
2
3
4
5
6
7
8
9
10
11
// src/utils/http.ts (假设你的 axios 实例在这里)
import axios from 'axios'

const httpInstance = axios.create({
baseURL: '/api', // 所有请求都会自动带上 /api 前缀
timeout: 5000
})

// ... 请求/响应拦截器

export default httpInstance

2.3.3 创建 API、类型与 Store

现在,数据链路的前端部分可以安心地基于 /api 前缀来构建了。

1. 创建类型文件 src/types/user.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
// src/types/user.ts

// 定义用户信息的类型接口
export interface UserInfo {
id: string;
account: string;
password?: string; // 登录后不返回密码
accessToken: string; // 统一使用 accessToken
refreshToken: string; // 添加刷新令牌
avatar: string;
nickname: string;
mobile: string;
gender: string;
birthday: string;
cityCode: string;
provinceCode: string;
profession: string;
}

// 定义登录表单的类型接口
export interface LoginForm {
account: string;
password: string;
}

2. 创建 API 文件 src/apis/user.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/apis/user.ts
import httpInstance from "@/utils/http";
import type { LoginForm, UserInfo } from "@/types/user";

// 登录接口
// 返回包含用户信息和双 Token 的完整数据
export const loginApi = (data: LoginForm): Promise<{ result: UserInfo }> => {
return httpInstance({
url: "/login",
method: "POST",
data,
});
};

3. 创建 Store 文件 src/stores/user.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
// src/stores/user.ts
import { defineStore } from "pinia";
import { ref } from "vue";
import { loginApi } from "@/apis/user"; // 导入登录接口
import type { UserInfo, LoginForm } from "@/types/user";

export const useUserStore = defineStore(
"user",
() => {
// 1. 定义 state
// 使用 ref <UserInfo | object> 表示 userInfo 可以是一个空对象或符合 UserInfo 类型的对象
const userInfo = ref<UserInfo | object>({});

// 2. 定义 action - 获取用户信息 (登录)
const getUserInfo = async (form: LoginForm) => {
const res = await loginApi(form);
userInfo.value = res.result;
};

// 3. 定义 action - 清除用户信息
const clearUserInfo = () => {
userInfo.value = {} as object;
};

return {
userInfo,
getUserInfo,
clearUserInfo,
};
},
{
persist: true,
},
);

2.3.4 组件改造与状态绑定

LayoutNav.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
<script setup lang="ts">
import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router";
import type { UserInfo } from "@/types/user";
const userStore = useUserStore();
const router = useRouter();

// 退出登录
const handleLogout = () => {
userStore.clearUserInfo();
router.push("/login");
};
</script>

<template>
<nav class="app-topnav">
<div class="container">
<ul class="app-topnav__list">
<template v-if="userStore.isLoggedIn">
<li class="app-topnav__item">
<a href="javascript:;" class="app-topnav__link"
><i-ep-user app-topnav__icon />{{
(userStore.userInfo as UserInfo).nickname
}}</a
>
</li>
<li class="app-topnav__item">
<el-popconfirm
title="确认退出吗?"
confirm-button-text="确认"
cancel-button-text="取消"
@confirm="handleLogout"
>
<template #reference>
<a href="javascript:;" class="app-topnav__link">退出登录</a>
</template>
</el-popconfirm>
</li>
<li class="app-topnav__item">
<router-link to="/member/order" class="app-topnav__link"
>我的订单</router-link
>
</li>
<li class="app-topnav__item">
<router-link to="/member" class="app-topnav__link"
>会员中心</router-link
>
</li>
</template>

<template v-else>
<li class="app-topnav__item">
<a
href="javascript:;"
class="app-topnav__link"
@click="$router.push('/login')"
>请先登录</a
>
</li>
<li class="app-topnav__item">
<a href="javascript:;" class="app-topnav__link">帮助中心</a>
</li>
<li class="app-topnav__item">
<a href="javascript:;" class="app-topnav__link">关于我们</a>
</li>
</template>
</ul>
</div>
</nav>
</template>

2.3.5 即时效果验证

现在,我们拥有了完整且真实的前后端联动链路!

  1. 确保 pnpm run devpnpm run mock 都在运行。

  2. 要真正测试登录效果,需要开发登录页面并调用 userStore.login 方法。

  3. 但我们可以先用 curl 测试 代理是否生效

    1
    2
    # 注意:这次我们请求的是 Vite dev server 的地址,带 /api 前缀!
    curl -X POST -H "Content-Type: application/json" -d "{\"account\": \"user1\", \"password\": \"123456\"}" http://localhost:5173/api/login

如果返回了成功的 JSON 数据,说明你的代理配置完全正确!你的前端应用现在已经具备了和后端无缝通信的能力。


2.4 响应式站点头部 (LayoutHeader) 开发

站点头部是用户交互的核心区域。在本次实战中,我们将构建一个 智能的、响应式的导航栏:它能感知当前所在的页面,在首页时默认透明以展示背景,在其他页面则为常规白色背景。当用户向下滚动时,它能平滑地切换为不透明的吸顶状态,确保导航始终可用。

当前任务: 2.4 - 响应式站点头部 (LayoutHeader) 开发
任务目标: 建立可复用的动画样式,采用简洁的 Layout 布局,并开发一个能根据 当前路由滚动位置 动态改变样式的 LayoutHeader 组件。

2.4.1 前置步骤:创建可复用的动画工具 (_utilities.scss)

在开发交互复杂的组件前,最佳实践是 将可复用的 CSS 动画抽象成独立的工具

1. 创建 _utilities.scss 文件
src/styles/abstracts/ 目录下,创建一个新文件 _utilities.scss

2. 编写动画工具代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/styles/abstracts/_utilities.scss

// 顶部滑入动画的关键帧
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

// 顶部滑入动画的占位符选择器
%slide-down-animation {
// 应用滑入动画
animation: slideDown 0.3s ease-out forwards;
}

3. 技术解读:@mixin vs @extend (占位符选择器)

开发者日记
开发中

架构师,我看到这里用了一个 %slide-down-animation,这是什么语法?它和 @mixin 有什么区别?

架构师

问得好。% 定义的是一个“占位符选择器”,通过 @extend 来使用。它和 @mixin 都是 SCSS 中实现代码复用的方式,但底层原理完全不同,适用于不同场景。

有什么不同?

架构师

@mixin 是将代码块 复制 到每一个调用它的地方。如果 10 个类都 @include 同一个 mixin,编译后的 CSS 里就会有 10 份重复的代码。而 @extend 则是将所有使用它的选择器(比如 .class-a, .class-b聚合 到一起,共用一个样式块。最终编译出来的 CSS 可能是 .class-a, .class-b { ... },代码 只有一份

我明白了。所以对于这种通用的、无参数的动画效果,用 @extend 更高效,因为它能显著减少最终 CSS 文件的体积。

架构师

完全正确。这就是选择 @extend 的核心原因——性能优化和代码优雅。

4. Vite 自动化注入
最后,让这个新的工具文件能被全局自动注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vite.config.ts
// ...
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "@/styles/abstracts/variables" as *;
@use "@/styles/abstracts/mixins" as *;
@use "@/styles/abstracts/utilities" as *; // 新增这一行
`,
},
},
},
// ...

2.4.2 步骤一:搭建组件基础结构 (Template)

我们的第一步是定义组件的 HTML 骨架。在这个阶段,我们只关心“组件里有什么”,比如 Logo、导航列表和功能按钮,并使用 v-for 配合一个 临时的静态数据 来渲染导航项。

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
<template>
<!-- 根元素,动态绑定 class 以便后续实现吸顶效果 -->
<!-- 注意:这里的 y 变量我们会在步骤三中定义 -->
<header :class="{ 'app-header': true, 'app-header-sticky': y > 100 }">
<div class="container">
<!-- 1. 左侧 Logo 区域 -->
<div class="app-header__logo">
<RouterLink to="/">
<img src="@/assets/images/logo.png" alt="格力专卖店" class="app-header__logo-img">
</RouterLink>
</div>

<!-- 2. 中间导航链接区域 -->
<ul class="app-header__nav">
<!-- 使用 v-for 遍历本地的静态数组 navigatorList -->
<li class="app-header__nav-item" v-for="item in navigatorList" :key="item.text">
<RouterLink :to="item.to" class="app-header__nav-link">
{{ item.text }}
</RouterLink>
</li>
</ul>

<!-- 3. 右侧功能区 -->
<div class="app-header__actions">
<!-- 搜索 -->
<div class="app-header__search">
<i-ep-search />
<button class="app-header__search-btn">查询</button>
</div>
<!-- 语言切换 -->
<div class="app-header__lang">
<a href="javascript:;" class="app-header__lang-link">EN</a>
</div>
<!-- 购物车 (作为子组件引入) -->
<LayoutCart />
</div>
</div>
</header>
</template>

代码解读:

  • 关注点: 我们只定义了 HTML 结构,使用了 div, ul, li, RouterLink 等标签。
  • 静态数据驱动: <li v-for="item in navigatorList" ...> 表明导航列表是由一个名为 navigatorList 的数组驱动的。这个数组我们将在步骤三的 <script> 部分定义。
  • 为交互预留接口: <header :class="{...}"> 已经为后续的动态样式切换做好了准备。

2.4.3 步骤二:添加组件样式 (Style)

结构完成后,我们用 SCSS 来美化组件,定义它的布局、颜色和外观。

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
<style lang="scss" scoped>
.app-header {
background: #fff;
height: 70px;
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.1);

.container {
@include flex-center;
margin-left: 40px;
justify-content: space-between;
height: 70px;
}
}

.app-header-sticky {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 999;
@extend %slide-down-animation;
}

.app-header__logo {
width: 160px;

a {
display: block;
height: 40px;
width: 100%;
}

&-img {
height: 40px;
width: auto;
max-width: 100%;
object-fit: contain;
}
}

.app-header__nav {
display: flex;
align-items: center;
justify-content: flex-start;
position: relative;
z-index: 998;
flex: 1;
padding-left: 40px;

&-item {
margin-right: 0;
position: relative;

// 使用border添加竖杠装饰,除了最后一个
&:not(:last-child) {
border-right: 1px solid $borderColor;
margin-right: 32px;
padding-right: 32px;
}
}

&-link {
font-size: 1.6rem;
line-height: 3.2rem;
height: 3.2rem;
padding: 0.8rem 1.2rem;
display: inline-block;
color: $textColor-secondary;
text-decoration: none;
transition: all $transition-duration ease;
@include truncate-text;
position: relative;

&:hover {
color: $GLColor;
background-color: $bgColor;
}

// 活跃状态
&.router-link-exact-active {
color: $GLColor;
font-weight: 500;
}
}
}

.app-header__actions {
display: flex;
align-items: center;
gap: 20px;
}

.app-header__search {
&-btn {
font-size: 1.6rem;
color: $textColor-secondary;
background: none;
border: none;
padding: 0.8rem 1.2rem;
cursor: pointer;
transition: color $transition-duration ease;

&:hover {
color: $GLColor;
}
}
}

.app-header__lang {
&-link {
font-size: 1.6rem;
color: $textColor-secondary;
text-decoration: none;
padding: 0.8rem 1.2rem;
transition: color $transition-duration ease;

&:hover {
color: #004098;
}
}
}
</style>

代码解读:

  • 默认与吸顶分离: 我们定义了 .app-header 的默认样式,以及一个独立的 .app-header-sticky 类来专门处理吸顶后的样式。这种分离使得逻辑非常清晰。
  • Flexbox 布局: 再次使用 Flexbox 来高效地实现横向排列和对齐。
  • 动画占位符: @extend %slide-down-animation; 应用了我们在 _utilities.scss 中定义的动画,当 .app-header-sticky 类被激活时,这个动画就会播放。

2.4.4 步骤三:定义静态数据与实现吸顶交互 (Script)

最后,我们编写 <script> 部分。在这里,我们将 定义临时的导航数据,并 引入 @vueuse/core 来实现吸顶的动态效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup lang="ts">
import LayoutCart from './LayoutCart.vue'
import { useScroll } from '@vueuse/core'

// 1. 定义静态导航列表数据
// 在这个阶段,我们使用本地数组作为数据源,为后续接入真实数据做准备。
const navigatorList = [
{ text: '首页', to: '/' },
{ text: '家用空调', to: '/category/1' },
{ text: '中央空调', to: '/category/2' },
{ text: '生活家电', to: '/category/3' },
{ text: '冰箱', to: '/category/4' },
{ text: '洗衣机', to: '/category/5' },
]

// 2. 使用 @vueuse/core 监听窗口滚动
// useScroll 会返回一个包含滚动坐标的对象,我们解构出 y 坐标 (垂直滚动距离)
// y 是一个 ref,它的值会随着页面滚动而实时更新
const { y } = useScroll(window)
</script>

代码解读与交互连接:

  1. 定义虚拟数据 (navigatorList): 我们在 <script> 内部创建了一个名为 navigatorList 的常量数组。模板中的 v-for 会遍历这个数组,从而将导航链接渲染到页面上。这完美地模拟了有数据时的情景,同时又将数据获取的复杂性留到了后续章节。

  2. 实现吸顶逻辑 (useScroll):

    • 我们从 @vueuse/core 库中导入 useScroll 函数。
    • const { y } = useScroll(window) 会创建一个响应式变量 y,它实时反映了页面垂直滚动的距离。
    • 联动效应: 这个 y 变量就是连接 <script> 逻辑和 <template> 样式的桥梁。
      • 回到模板中的 :class="{ 'app-header-sticky': y > 100 }"
      • 当页面在顶部时,y0y > 100false,所以 app-header-sticky 会被添加。
      • 当用户向下滚动,y 的值超过 100 时,y > 100 变为 true,Vue 会自动为 <header> 元素 添加 app-header-sticky 类。
      • 这个类的添加会触发我们在步骤二中写好的 position: fixed 等样式,从而实现吸顶效果,并播放滑入动画。

通过这三个步骤,我们清晰地分离了结构、样式和行为,首先用静态数据构建了一个完整的、外观正确的组件,然后无缝地为其增加了核心的吸顶交互功能,完全符合当前笔记章节的目标。


2.5 Pinia 实战 - 动态导航与本地化静态资源

静态的占位导航无法满足我们电商项目的需求。现在,我们将 以前后端完整联动的专业视角,为头部导航注入动态数据。我们将分步升级模拟后端,使其能够提供一份 可控的静态 JSON 数据托管本地图片资源。随后,我们将创建前端的 API 层、类型定义和 Pinia Store,最终实现导航数据的动态渲染。

当前任务: 2.5 - Pinia 实战 - 动态导航与本地化静态资源
任务目标: 搭建一个能同时提供 API 和静态文件服务的 json-server,并建立一个类型安全的“API -> Store -> Component”数据流,用真实的、图片本地化的模拟数据替换静态导航。

第一步:升级模拟后端 (Mock Server)

为了让开发环境完全自给自足,摆脱对外部链接的依赖,我们需要对 json-server 进行两项关键升级:1. 提供固定的、来自 JSON 文件的分类数据。 2. 兼任静态文件服务器,托管导航所需的本地图片。

1. 准备静态资源 (数据与图片)

首先,在 mock/ 目录下创建 mock-data.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
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
{
"categories": [
{
"id": "new",
"name": "新品",
"icon": "/src/assets/icons/icon-New-product.png",
"products": [
{
"id": 1,
"name": "自然又自在",
"desc": "格力·至尊 家居生活新中心",
"picture": "/images/new/product1.jpg",
"type": "large"
},
{
"id": 2,
"name": "循环风扇",
"desc": "臻品工艺 拂动盛夏",
"picture": "/images/new/product2.jpg",
"type": "normal"
},
{
"id": 3,
"name": "空气净化器",
"desc": "森林级空气管家",
"picture": "/images/new/product3.jpg",
"type": "normal"
},
{
"id": 4,
"name": "晶弘魔法冰箱",
"desc": "鲜嫩两星期 轻触一刀切",
"picture": "/images/new/product4.jpg",
"type": "wide"
},
{
"id": 5,
"name": "热泵洗衣机",
"desc": "37℃烘干不伤衣",
"picture": "/images/new/product5.jpg",
"type": "tall"
}
]
},
{
"id": "home-air-conditioner",
"name": "家用空调",
"icon": "/src/assets/icons/icon_Air-Conditioner-02@2x.png",
"products": [
{
"id": 1,
"name": "家居的美学绅士",
"desc": "格力雨索系列,时光淬炼",
"picture": "/images/home-ac/product1.jpg",
"type": "large"
},
{
"id": 2,
"name": "格力·金眠空调",
"desc": "静享好眠 美梦甜甜",
"picture": "/images/home-ac/product2.jpg",
"type": "normal"
},
{
"id": 3,
"name": "格力·高温王空调",
"desc": "挑战65℃酷暑制冷不衰减",
"picture": "/images/home-ac/product3.jpg",
"type": "normal"
},
{
"id": 4,
"name": "格力艺术空调",
"desc": "科技美学 风华绝代",
"picture": "/images/home-ac/product4.jpg",
"type": "wide"
},
{
"id": 5,
"name": "格力新风空调",
"desc": "双向新风 恒氧新居",
"picture": "/images/home-ac/product5.jpg",
"type": "tall"
}
]
},
{
"id": "central-air-conditioner",
"name": "中央空调",
"icon": "/src/assets/icons/icon_Home-central-air-conditioning-02@2x.png",
"products": [
{
"id": 1,
"name": "用电省一半",
"desc": "格力智睿新一代家庭中央空调",
"picture": "/images/central-ac/product1.jpg",
"type": "large"
},
{
"id": 2,
"name": "厨享",
"desc": "不沾油烟的空调",
"picture": "/images/central-ac/product2.jpg",
"type": "normal"
},
{
"id": 3,
"name": "寐享",
"desc": "地毯式制热,淋浴式制冷",
"picture": "/images/central-ac/product3.jpg",
"type": "normal"
},
{
"id": 4,
"name": "铂韵",
"desc": "低温制热温暖,高温制冷舒适",
"picture": "/images/central-ac/product4.jpg",
"type": "wide"
},
{
"id": 5,
"name": "舒睿",
"desc": "低温制热温暖,高温制冷舒爽",
"picture": "/images/central-ac/product5.jpg",
"type": "tall"
}
]
},
{
"id": "home-appliances",
"name": "生活电器",
"icon": "/src/assets/icons/icon_home-devices-02@2x.png",
"products": [
{
"id": 1,
"name": "净云星抽油烟机",
"desc": "内腔6年免清洗",
"picture": "/images/appliances/product1.jpg",
"type": "large"
},
{
"id": 2,
"name": "循环扇",
"desc": "循环鲜风 全屋瞬爽",
"picture": "/images/appliances/product2.jpg",
"type": "normal"
},
{
"id": 3,
"name": "百香煲",
"desc": "地道柴火饭,香郁好滋味",
"picture": "/images/appliances/product3.jpg",
"type": "normal"
},
{
"id": 4,
"name": "嵌入式洗碗机",
"desc": "双效烘干,洁净一体",
"picture": "/images/appliances/product4.jpg",
"type": "wide"
},
{
"id": 5,
"name": "净化器",
"desc": "高效净化 畅享鲜氧",
"picture": "/images/appliances/product5.jpg",
"type": "tall"
}
]
},
{
"id": "refrigerator",
"name": "冰箱",
"icon": "/src/assets/icons/icon_refrigerator-02@2x.png",
"products": [
{
"id": 1,
"name": "晶弘魔法冰箱",
"desc": "鲜嫩两星期,轻触一刀切",
"picture": "/images/refrigerator/product1.jpg",
"type": "large"
},
{
"id": 2,
"name": "十字养鲜系列",
"desc": "长效净味 干湿分储",
"picture": "/images/refrigerator/product2.jpg",
"type": "normal"
},
{
"id": 3,
"name": "无霜保鲜系列",
"desc": "无霜保鲜 鲜活原味",
"picture": "/images/refrigerator/product3.jpg",
"type": "normal"
},
{
"id": 4,
"name": "海蕴藏鲜系列",
"desc": "微晶-5℃,广域广净广鲜",
"picture": "/images/refrigerator/product4.jpg",
"type": "wide"
},
{
"id": 5,
"name": "独立储鲜系列",
"desc": "抽屉专储 原味保鲜",
"picture": "/images/refrigerator/product5.jpg",
"type": "tall"
}
]
},
{
"id": "washing-machine",
"name": "洗衣机",
"icon": "/src/assets/icons/icon_washing-machine-02@2x.png",
"products": [
{
"id": 1,
"name": "格力净护洗衣机",
"desc": "洗衣 我想净静",
"picture": "/images/washer/product1.jpg",
"type": "large"
},
{
"id": 2,
"name": "共享洗衣机",
"desc": "扫码可用 洗烘生香",
"picture": "/images/washer/product2.jpg",
"type": "normal"
},
{
"id": 3,
"name": "净静洗衣机",
"desc": "净享洁净 静享生活",
"picture": "/images/washer/product3.jpg",
"type": "normal"
},
{
"id": 4,
"name": "净柔洗衣机",
"desc": "健康活水 柔护衣物",
"picture": "/images/washer/product4.jpg",
"type": "wide"
},
{
"id": 5,
"name": "热泵洗衣机",
"desc": "37℃烘干不伤衣",
"picture": "/images/washer/product5.jpg",
"type": "tall"
}
]
},
{
"id": "water-heater",
"name": "热水器",
"icon": "/src/assets/icons/icon_Water-heater-02@2x.png",
"products": [
{
"id": 1,
"name": "24小时不间断热水供应",
"desc": "沐鑫空气能热水器",
"picture": "/images/heater/product1.jpg",
"type": "large"
},
{
"id": 2,
"name": "安沐星",
"desc": "安全沐浴守护星",
"picture": "/images/heater/product2.jpg",
"type": "normal"
},
{
"id": 3,
"name": "舒铂热水器",
"desc": "全能速热,舒心浴上",
"picture": "/images/heater/product3.jpg",
"type": "normal"
},
{
"id": 4,
"name": "舒沐享燃气热水器",
"desc": "四季舒享 恒温沐浴",
"picture": "/images/heater/product4.jpg",
"type": "wide"
},
{
"id": 5,
"name": "水之沁",
"desc": "高效节能,多重防护",
"picture": "/images/heater/product5.jpg",
"type": "tall"
}
]
}
]
}

接下来,在项目根目录的 public/ 文件夹下,创建 images 目录及相应的子目录(如 new, home-ac 等),并将所有商品图片按 mock-data.jsonpicture 字段指定的路径存放。

2. 配置 json-server 托管静态文件

打开 package.json,为 mock 启动脚本添加 --static 标志,指定 public 目录为静态资源根目录。

1
2
3
4
5
6
7
8
9
// package.json
{
"scripts": {
"dev": "vite",
// 增加 --static ./public 参数
"mock": "node mock/server.cjs --port 3001 --static ./public"
// ...
}
}

代码解读:

  • --static ./public: 此参数告知 json-server,将 ./public 目录作为静态文件服务的根目录。现在,当浏览器请求 http://localhost:3001/images/new/product1.jpg 时,服务器会直接返回 public/images/new/product1.jpg 这个文件。

3. 从文件加载静态数据

修改 mock/generate-data.cjs,使其不再使用 Faker 生成分类数据,而是从我们刚刚创建的 mock-data.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
// mock/generate-data.cjs
const { faker } = require("@faker-js/faker");
const fs = require("fs");
const path = require("path");

module.exports = () => {
// 1. 读取外部 JSON 文件
const staticDataPath = path.join(__dirname, "mock-data.json");
const staticData = JSON.parse(fs.readFileSync(staticDataPath, "utf-8"));

const data = {
users: [],
// 2. 直接使用从文件中读取的 categories 数据
categories: staticData.categories,
};

// 用户数据仍然可以由 Faker 动态生成(逻辑保持不变)
for (let i = 1; i <= 20; i++) {
data.users.push({
id: i,
name: faker.person.fullName(),
email: faker.internet.email(),
});
}

return data;
};

代码解读:

  • 我们引入了 Node.js 的 fspath 模块来处理文件读写和路径。
  • fs.readFileSync 同步读取 mock-data.json 的内容。
  • JSON.parse 将文件内容从字符串解析为 JavaScript 对象。
  • 最终,返回数据中的 categories 字段被替换为来自文件的静态数据,实现了数据的可控性。

4. 自定义 API 路由
为了让后端接口更符合企业级开发规范(例如,返回带有状态码和消息的统一结构),我们可以打开 mock/server.cjs 文件,在 server.use(router) 之前添加一个自定义路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// mock/server.cjs

// ... (前面的 require 和 server 实例创建代码)

// 自定义 /categories 路由以符合 OpenAPI 规范
server.get("/categories", (req, res) => {
const db = router.db; // 获取数据库实例
const categories = db.get("categories").value();

// 返回符合规范的包装结构
res.status(200).json({
code: "200",
msg: "操作成功",
result: categories,
});
});

第二步:构建前端数据流 (API -> Store -> Component)

后端准备就绪后,我们开始搭建前端的“数据管道”。

1. 定义 TypeScript 类型
根据 mock-data.json 的数据结构,在 src/types/ 目录下创建 category.ts 文件,定义精确的类型接口。这能为我们提供强大的代码提示和类型安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/types/category.ts
// 后续我们会用到 Product,这里先留着
export interface Product {
id: string;
name: string;
desc: string;
picture: string;
type: "large" | "normal" | "wide" | "tall";
}

// 一级分类项
export interface CategoryItem {
id: string;
name: string;
icon: string;
products: Product[];
// 注意:根据 OpenAPI 文档和实际数据,children 是可选的
// 二级分类项
children?: CategoryItem[];
}

2. 创建 API 请求函数
src/apis/ 目录下创建 layout.ts,用于统一管理布局相关的 API 请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/apis/layout.ts
import httpInstance from "@/utils/http";
import type { CategoryItem } from "@/types/category";

// 定义接口返回数据的外层结构
interface ApiResponse {
code: string;
msg: string;
result: CategoryItem[];
}

// 获取导航分类数据的 API
export const getCategoryAPI = (): Promise<ApiResponse> => {
return httpInstance({
url: "/categories",
});
};

3. 创建 Pinia Store
src/stores/ 目录下创建 categoryStore.ts,用于获取并存储全局共享的导航分类数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/stores/categoryStore.ts
import { defineStore } from "pinia";
import { ref } from "vue";
import { getCategoryAPI } from "@/apis/layout";
import type { CategoryItem } from "@/types/category";

export const useCategoryStore = defineStore("category", () => {
// state: 导航列表数据
const categoryList = ref<CategoryItem[]>([]);

// action: 获取导航数据的方法
const getCategory = async () => {
const res = await getCategoryAPI();
// 从包装好的响应中取出 result
categoryList.value = res.result;
};

return {
categoryList,
getCategory,
};
});

第三步:组件改造与动态渲染

万事俱备,现在我们改造 LayoutHeader.vue 组件,让它从 Pinia Store 中获取数据并动态渲染导航。

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
<!-- src/components/LayoutHeader.vue -->
<script setup lang="ts">
import { useScroll } from '@vueuse/core'
import { useCategoryStore } from '@/stores/categoryStore'
import { onMounted } from 'vue'

// 1. 获取 useCategoryStore 实例
const categoryStore = useCategoryStore()


// 2.解构出Store中的List数据
const { categoryList } = storeToRefs(categoryStore)

// 3. 在组件挂载时调用 action 获取数据
onMounted(() => {
categoryStore.getCategory()
})
</script>

<template>
<!-- 中间导航,核心只修改这个内容即可 -->
<ul class="app-header__nav">
<li class="app-header__nav-item" v-for="item in categoryList" :key="item.id">
<!-- 使用模板字符串拼接出正确的路由地址 -->
<RouterLink :to="`/category/${item.id}`" class="app-header__nav-link">
{{ item.name }}
</RouterLink>
</li>
</ul>
</template>

第四步:端到端验证

  1. 启动服务: 确保终端中 pnpm run dev (前端) 和 pnpm run mock (后端) 两个命令都在运行。
  2. 验证 API: 在浏览器中访问 http://localhost:3001/categories。您应该能看到 mock-data.json 中的内容被一个包含 code, msg, result 的对象包裹着返回。
  3. 验证静态资源: 复制 mock-data.json 中任一 picture 路径 (例如 /images/new/product1.jpg),然后在浏览器中访问 http://localhost:3001/images/new/product1.jpg,确认能看到对应的图片。
  4. 验证前端渲染: 刷新或打开 http://localhost:5173/ 页面。此时,您的头部导航栏应该已经不再是静态文字,而是被 mock-data.json 中的 name 字段动态渲染出来了。

2.6 站点底部 (LayoutFooter) 开发

站点底部是应用信息架构的重要组成部分。在这一节,我们将构建一个结构清晰、样式简洁的静态页脚,专注于基础的 HTML 结构和精准的 SCSS 样式控制。

当前任务: 2.6 - 站点底部 (LayoutFooter) 组件化开发
文件路径: src/views/Layout/components/LayoutFooter.vue
任务目标: 开发一个干净、经典的静态页脚,重点掌握 BEM 命名规范和 SCSS 的 &:not() 选择器技巧。

2.6.1 数据层 (<script>): 定义链接内容

尽管我们的页脚很简单,但遵循“数据与视图分离”的原则总是一个好习惯。我们将页脚需要展示的链接定义在一个数组中,这样未来修改链接时会非常方便。

1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts">
// 定义底部的版权链接数组
const footerLinks = [
{ text: '关于我们', href: '#' },
{ text: '帮助中心', href: '#' },
{ text: '售后服务', href: '#' },
{ text: '配送与验收', href: '#' },
{ text: '商务合作', href: '#' },
{ text: '搜索推荐', href: '#' },
]
</script>

代码解读:

  • 我们创建了一个 footerLinks 数组来统一管理页脚的导航链接。
  • 这样做的好处是,当需要增删或修改链接时,我们只需要操作这个数组,而无需改动下面的模板 (<template>) 代码,使维护变得简单。

2.6.2 视图层 (<template>): 搭建基本结构

接下来,我们编写模板。这里将使用标准的 HTML 标签,并通过 v-for 指令将我们定义好的数据动态渲染出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<footer class="app-footer">
<div class="container">
<div class="app-footer__content">
<p class="app-footer__links">
<a v-for="(link, index) in footerLinks" :key="index" :href="link.href" class="app-footer__link">
{{ link.text }}
</a>
</p>
<p class="app-footer__copyright-text">CopyRight © 格力商城</p>
</div>
</div>
</footer>
</template>

2.6.3 样式层 (<style>): 添加 SCSS 样式

最后,我们为页脚添加样式。这里的重点是使用 SCSS 的嵌套语法和 :not() 选择器来实现链接之间的分隔线效果。

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
<style scoped lang='scss'>
.app-footer {
background-color: #333;

.app-footer__content {
height: 170px;
padding-top: 40px;
text-align: center;
color: #999;
font-size: 15px;

.app-footer__links {
margin-bottom: 20px;
}

.app-footer__copyright-text {
margin-bottom: 20px;
}

.app-footer__link {
color: #999;
padding: 0 10px;
text-decoration: none;

&:not(:first-child) {
border-left: 1px solid #999;
}
}
}
}
</style>

代码解读:

  • 分隔线技巧: &:not(:first-child) 是一个非常实用的伪类选择器。
    • & 指代的是当前选择器,也就是 .footer-link
    • :not(:first-child) 的意思是“选择所有不是其父元素的第一个子元素的 .footer-link”。
    • 组合起来,就实现了为第二个、第三个…直到最后一个链接都添加 border-left,而第一个链接则不受影响,从而完美地创建了链接之间的分隔线。

2.7 TanStack Query 实战 - 首页轮播图 (HomeBanner) 开发

首页轮播图是吸引用户眼球、转化流量的核心入口。在本节中,我们将首次引入强大的异步状态管理库——TanStack Query,来取代传统的 onMounted + ref 数据获取模式,并结合 Element Plus 组件,开发一个功能完整、体验优雅的轮播图。

当前任务: 2.7 - TanStack Query 实战 - 首页轮播图 (HomeBanner) 开发
任务目标: 扩展模拟后端以支持轮播图 API,使用 TanStack Query (useQuery) 获取数据,并用 ElCarouselElSkeleton 构建一个带加载占位效果的动态轮播图组件。

2.7.1 升级模拟后端:支持轮播图 API

与上一节类似,我们的第一步是让模拟后端具备提供轮播图数据的能力。

1. 准备静态资源 (数据与图片)
首先,在 mock/ 目录下打开 mock-data.json 文件,在其中新增一个 banners 数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
banners: [
{
id: "banner-001",
imgUrl: "/images/carousel/carousel1.jpg",
hrefUrl: "/category/cat-001",
},
{
id: "banner-002",
imgUrl: "/images/carousel/carousel2.jpg",
hrefUrl: "/category/cat-002",
},
{
id: "banner-003",
imgUrl: "/images/carousel/carousel3.jpg",
hrefUrl: "/category/cat-003",
},
],

请确保 您已在 public/images/ 目录下创建了 carousel 文件夹,并放入了 carousel1.jpgcarousel4.jpg 四张图片。

2. 在 server.cjs 中添加自定义路由
打开 mock/server.cjs,为 /home/banner 这个 API 端点添加一个自定义的 GET 路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// mock/server.cjs
// ...
server.get("/home/category/head", (req, res) => {
// ... 分类接口逻辑 ...
});

// 新增:自定义 /home/banner 路由
server.get("/home/banner", (req, res) => {
const db = router.db;
const banners = db.get("banners").value();

res.status(200).json({
code: "1",
msg: "操作成功",
result: banners,
});
});

server.use(router);
// ...

3. 重启并验证 Mock 服务

  • 停止 (Ctrl+C) 并重新运行 pnpm run mock
  • 在浏览器中访问 http://localhost:3001/home/banner
  • 预期效果: 你应该能看到包含 4 个轮播图对象的 result 数组,并且其中的 imgUrl 都是我们本地的路径。

2.7.2 创建前端数据流 (API -> Type -> Component)

开发者日记
开发中

架构师,后端接口准备好了。按照之前的经验,我是不是要去 onMounted 里调用 API,然后用一个 ref 来存数据?

架构师

这是一种可行的方式,但也是我们今天要“革命”的传统模式。我们将引入 TanStack Query

它和传统方式相比,好在哪里?

架构师

好处是颠覆性的。你不再需要手动管理 isLoading, error 这些状态,TanStack Query 会自动为你提供。它还会自动缓存数据,当组件再次挂载时,会立即从缓存中显示旧数据,同时在后台“静默”地请求新数据,用户体验极佳。

听起来很强大。那我该怎么用它?

架构师

核心就是 useQuery 这个 hook。你只需要给它两样东西:一个唯一的“查询键”(queryKey),用来标识这份数据;一个“查询函数”(queryFn),也就是我们即将封装的 getBannerApi。剩下的所有事情,TanStack Query 都会帮你优雅地处理好。

1. 创建类型文件 src/types/home.ts

1
2
3
4
5
6
7
8
// src/types/home.ts
export interface BannerItem {
id: string;
// 图片地址
imgUrl: string;
// 跳转地址
hrefUrl: string;
}

2. 创建 API 文件 src/apis/home.ts

1
2
3
4
5
6
7
8
9
// src/apis/home.ts
import httpInstance from "@/utils/http";
import type { BannerItem } from "@/types/home";

export const getBannerApi = (): Promise<{ result: BannerItem[] }> => {
return httpInstance({
url: "/home/banner",
});
};

3. 开发 HomeBanner 组件
现在,我们创建 src/views/Home/components/HomeBanner.vue,并在这里实战 useQuery

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
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { getBannerApi } from '@/apis/home'

const { data: bannerList, isLoading } = useQuery({
// queryKey: 唯一的查询键,用于缓存和识别
// 当 key 变化时,TanStack Query 会重新执行查询
queryKey: ['homeBanner'],

// queryFn: 一个返回 Promise 的查询函数
queryFn: async () => {
const res = await getBannerApi()
return res.result
}
})
</script>

<template>
<div class="home-banner">
<el-skeleton style="width: 100%; height: 500px" :loading="isLoading" animated>
<!-- 骨架屏内部使用#template用于渲染占位内容 -->
<template #template>
<el-skeleton-item variant="image" style="width: 100%; height: 100%;" />
</template>

<!-- 骨架屏内部使用#default用于渲染实际内容 -->
<template #default>
<el-carousel height="500px">
<el-carousel-item v-for="item in bannerList" :key="item.id">
<img :src="item.imgUrl" :alt="item.id">
</el-carousel-item>
</el-carousel>
</template>

</el-skeleton>
</div>
</template>

<style scoped lang='scss'>
.home-banner {
width: 100%;
max-width: none;
height: 500px;
margin: 0;


img {
width: 100%;
height: 100%;
object-fit: cover; // 确保图片完全填充容器,保持比例
object-position: center; // 居中显示
display: block; // 消除图片底部的默认间距
}
}
</style>

代码解读:

  • useQuery({ queryKey: ['homeBanner'], queryFn: ... }): 我们调用 useQuery,并解构出 data (我们重命名为 bannerList) 和 isLoading
  • ElSkeleton: 我们使用了 Element Plus 的骨架屏组件,并将其 loading 属性与 useQuery 返回的 isLoading 状态绑定。
  • #template#default 插槽: 这是 ElSkeleton 的用法,#template 定义了加载时骨架屏的样式,#default 定义了加载完成后要显示的内容。

2.7.3 集成到首页

最后,在 src/views/Home/index.vue 中引入并使用我们刚刚创建的 HomeBanner 组件。

1
2
3
4
5
6
7
8
9
10
<script setup lang="ts">
import HomeBanner from './components/HomeBanner.vue'
</script>

<template>
<HomeBanner />
</template>

<style scoped lang="scss">
</style>

2.8 首页-人气推荐 (HomeHotProduct) 板块开发


2.8.1 第一步:构建全局原子组件 (ProductCard.vue)

本节目标: 我们将从零开始,采用“视觉优先”的开发流程,完整地构建一个全局通用的 ProductCard.vue 组件。我们将先实现其静态视觉效果,然后通过重构优化样式,最后为其添加 PropsEmits 使其成为一个可复用的动态组件。


1. 搭建静态模板与初始样式

我们的第一步是创建一个视觉上完整的静态组件,暂时不考虑数据复用和逻辑。

文件路径: src/components/ProductCard.vue
任务目标: 创建组件文件,并编写包含硬编码内容的模板和足以实现完整视觉效果(包括悬停动画)的初始 SCSS 样式。

1.1 模板 (<template>)

请在 src/components/ 目录下创建 ProductCard.vue 文件,并写入以下模板代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div class="product-card">
<div class="product-card__image-wrapper">
<img src="/images/new/product2.jpg" alt="循环风扇" class="product-card__image" />
<div class="product-card__mask">
<h3 class="product-card__mask-title">
循环风扇
<span class="product-card__mask-desc">臻品工艺 拂动盛夏</span>
</h3>
<button class="product-card__btn">了解更多</button>
</div>
</div>
<div class="product-card__info">
<h3 class="product-card__name">
循环风扇
<span class="product-card__desc">臻品工艺 拂动盛夏</span>
</h3>
</div>
</div>
</template>

1.2 初始样式 (<style>)

接下来,我们编写实现设计效果所需的所有 SCSS 代码。请注意,此时代码中会存在一些重复。

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
<style lang="scss" scoped>
.product-card {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
cursor: pointer;

&:hover {
.product-card__image {
transform: scale(1.1);
}
.product-card__mask {
opacity: 1;
}
.product-card__mask-title,
.product-card__btn {
opacity: 1;
transform: translateY(0);
}
.product-card__info {
opacity: 0;
}
}
}

.product-card__image-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}

.product-card__image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 1.5s ease;
}

/* 注意:此处为重复代码区域 1 */
.product-card__mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 0;
transition: all 0.4s ease-in-out;
}

/* 注意:此处为重复代码区域 2 */
.product-card__mask-title {
font-size: 24px;
font-weight: 400;
color: #fff;
text-align: center;
margin: 0;
transform: translateY(-100px);
transition: all 0.2s ease-in-out;
}

.product-card__mask-desc {
display: block;
font-size: 14px;
font-weight: 400;
color: #fff;
line-height: 20px;
margin-top: 8px;
}

.product-card__btn {
padding: 0 20px;
height: 36px;
line-height: 36px;
color: #fff;
font-size: 14px;
border: 1px solid #fff;
border-radius: 36px;
background: transparent;
cursor: pointer;
opacity: 0;
transform: translateY(100px);
transition: all 0.2s ease-in-out 0.1s;

&:hover {
background: rgba(255, 255, 255, 0.1);
}
}

.product-card__info {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 20px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
transition: opacity 0.3s ease-in-out;
}

/* 注意:此处为重复代码区域 3 */
.product-card__name {
font-size: 24px;
font-weight: 400;
color: #111;
text-align: center;
margin: 0;
}

.product-card__desc {
display: block;
font-size: 14px;
font-weight: 400;
color: #111;
line-height: 20px;
margin-top: 8px;
}
</style>

2. 样式重构:提炼 Mixin

在完成初步视觉实现后,我们审查代码,发现 .product-card__mask 的样式定义和 .product-card__mask-title / .product-card__name 的文字样式存在明显重复。为提高代码质量和可维护性,我们将其提取为 @mixin

2.1 创建 Mixin

请打开 src/styles/abstracts/_mixins.scss 文件,并添加以下两个 mixin。

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
// src/styles/abstracts/_mixins.scss

// ... 已有的 mixins ...

// 产品卡片中重复的遮罩样式
@mixin product-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 0;
transition: all 0.4s ease-in-out;
}

// 产品卡片中重复的标题文字样式
@mixin product-text-style($color: #111) {
font-size: 24px;
font-weight: 400;
color: $color;
text-align: center;
margin: 0;
}

2.2 应用 Mixin

回到 ProductCard.vue,我们用 @include 替换掉之前重复的样式代码,我们之前已经全局引入了 Mixin,所以无需再引入

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
<style lang="scss" scoped>

.product-card {
/* ... 根元素和悬停效果,保持不变 ... */
}

/* ... __image-wrapper 和 __image 样式,保持不变 ... */

.product-card__mask {
@include product-mask;
}

.product-card__mask-title {
@include product-text-style(#fff);
transform: translateY(-100px);
transition: all 0.2s ease-in-out;
}

/* ... __mask-desc 和 __btn 样式,保持不变 ... */

.product-card__info {
/* ... 样式保持不变 ... */
}

.product-card__name {
@include product-text-style; // 使用默认颜色
}

.product-card__desc {
/* ... 样式保持不变 ... */
}
</style>

现在,我们的样式代码更加简洁和可维护。


3. 组件化改造:添加 Props 与 Emits

当前组件是静态的。为了让它可以被复用并显示不同商品的数据,我们需要为其定义 props。同时,为了让父组件能响应卡片上的用户操作,我们需要定义 emits

3.1 添加 <script setup> 逻辑

ProductCard.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
<script setup lang="ts">
import { Product } from '@/types/category'

interface Props {
product: Product
index: number
showMask?: boolean
showInfo?: boolean
}

interface Emits {
(e: 'click', product: Product): void
(e: 'hover', product: Product): void
(e: 'button-click', product: Product): void
}

const props = withDefaults(defineProps<Props>(), {
showMask: true,
showInfo: true
})

const emit = defineEmits<Emits>()

const handleClick = () => {
emit('click', props.product)
}

const handleHover = () => {
emit('hover', props.product)
}

const handleButtonClick = (event: Event) => {
event.stopPropagation() // 阻止事件冒泡
emit('button-click', props.product)
}
</script>

3.2 更新 <template>

最后,我们将模板中的硬编码内容替换为从 props 中获取的动态数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div class="product-card" @click="handleClick">
<div class="product-card__image-wrapper">
<img :src="product.picture" :alt="product.name" class="product-card__image" />
<div class="product-card__mask" :class="{ 'product-card__mask--no-text': index === 0 }">
<h3 v-if="index !== 0" class="product-card__mask-title">
{{ product.name }}
<span class="product-card__mask-desc">{{ product.desc }}</span>
</h3>
<button class="product-card__btn" @click="handleButtonClick">了解更多</button>
</div>
</div>
<div v-if="index !== 0" class="product-card__info">
<h3 class="product-card__name">
{{ product.name }}
<span class="product-card__desc">{{ product.desc }}</span>
</h3>
</div>
</div>
</template>

本节小结:
我们遵循“先视觉,后重构,再逻辑”的自然开发流程,成功地从零构建了一个视觉精美、代码健壮、高度可复用的 ProductCard.vue 全局组件。它现在已经准备好被用作我们应用中的基础“零件”。


2.8.2 搭建人气推荐主体框架 (HomeHotProduct.vue)

本节目标: 我们将从零创建 HomeHotProduct.vue 组件并将其集成到首页,以获得即时视觉反馈。您将学习如何识别并提取可复用的子组件(如标题区),并深入掌握如何应用 Element PlusElTabs 组件,通过 插槽:deep() 选择器,将其深度定制成符合我们设计稿的专业导航样式。


1. 创建主组件“画布”并集成到首页

一个高效的开发流程始于快速建立一个可以看到成果的“画布”。因此,我们的第一步不是埋头于子组件的细节,而是先创建主组件文件,并立即在首页中引用它。

1.1 创建 HomeHotProduct.vue 文件
请在 src/views/Home/components/ 目录下创建 HomeHotProduct.vue 文件,并填入一个简单的占位内容。

1
2
3
4
5
<template>
<div class="hot-product">
人气推荐模块
</div>
</template>

1.2 在首页 (Home/index.vue) 中引用
现在,打开 src/views/Home/index.vue,引入并使用我们刚刚创建的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup lang="ts">
import HomeBanner from './components/HomeBanner.vue'
import HomeHotProduct from './components/HomeHotProduct.vue' // 1. 导入
</script>

<template>
<div class="container">
<HomeBanner />
</div>

<HomeHotProduct />
</template>

<style lang="scss" scoped>
.container {
width: 100%;
max-width: none;
}
</style>

现在启动项目 (pnpm run dev),您应该能在轮播图下方看到“人气推荐模块”这几个字。这个即时的反馈回路,正是我们高效开发的基础。


2. 提取头部为独立组件

观察 HomeHotProduct.vue 的设计稿,我们能立刻识别出“热销产品”和“核心科技 品质精选”这部分在视觉上是一个独立的整体。为了保持 HomeHotProduct.vue 的整洁,我们将其提取为一个本地的、纯展示组件。

2.1 创建 HotProductHeader.vue
src/views/Home/components/components/ 目录下创建 HotProductHeader.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
<script setup lang="ts">
defineProps<{
title: string,
slogan: string
}>()
</script>

<template>
<div class="header">
<h2 class="title">{{ title }}</h2>
<p class="slogan">{{ slogan }}</p>
</div>
</template>

<style lang="scss" scoped>
.header {
text-align: center;
margin-bottom: 40px;
}
.title {
font-size: 48px;
font-weight: 600;
color: #111;
line-height: 67px;
margin-bottom: 10px;
}
.slogan {
font-size: 32px;
font-weight: 400;
color: #666;
line-height: 45px;
margin: 0;
}
</style>

2.2 在 HomeHotProduct.vue 中使用
现在,我们回到 HomeHotProduct.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
<script setup lang="ts">
import HotProductHeader from './components/HotProductHeader.vue'
</script>

<template>
<div class="hot-product">
<div class="container">
<HotProductHeader title="热销产品" slogan="核心科技 品质精选" />
</div>
</div>
</template>

<style lang="scss" scoped>
.hot-product {
padding: 32px 0;
background: #fff;

.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
}
</style>

刷新浏览器,您将看到样式精美的标题区已经出现了。


3. 使用并深度定制 ElTabs 组件

这是本节的核心教学点。面对设计稿中的 Tabs 导航,我们作为“务实的构建者”,首要思路就是利用 Element Plus 提供的能力。

3.1 引入并搭建 ElTabs 基础结构
我们先引入 ElTabs,并用静态数据快速搭建出基础的 Tabs 结构。

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
<script setup lang="ts">
import { ref } from 'vue'
import HotProductHeader from './components/HotProductHeader.vue'

const activeTab = ref('new')

// 分类数据(我们在上一个章节定义过的,我们现在提取出其中一部分)
// 定义驱动整个组件的本地静态数据
const categories = [
{
id: 'new',
name: '新品',
icon: '/src/assets/icons/icon_New product-02@2x.png',
products: [
{ id: 1, name: '自然又自在', desc: '格力·至尊 家居生活新中心', picture: '/images/new/product1.jpg', type: 'large' },
{ id: 2, name: '循环风扇', desc: '臻品工艺 拂动盛夏', picture: '/images/new/product2.jpg', type: 'normal' },
{ id: 3, name: '空气净化器', desc: '森林级空气管家', picture: '/images/new/product3.jpg', type: 'normal' },
{ id: 4, name: '热泵洗衣机', desc: '37℃烘干不伤衣', picture: '/images/new/product4.jpg', type: 'wide' },
{ id: 5, name: '晶弘魔法冰箱', desc: '鲜嫩两星期 轻触一刀切', picture: '/images/new/product5.jpg', type: 'tall' }
]
},
// 为了让 Tabs 显示完整,我们补全分类数据
{ id: 'home-ac', name: '家用空调', icon: '/src/assets/icons/icon_Air-Conditioner-02@2x.png', products: [] },
{ id: 'central-ac', name: '中央空调', icon: '/src/assets/icons/icon_Home-central-air-conditioning-02@2x.png', products: [] },
{ id: 'appliances', name: '生活电器', icon: '/src/assets/icons/icon_home-devices-02@2x.png', products: [] },
{ id: 'refrigerator', name: '冰箱', icon: '/src/assets/icons/icon_refrigerator-02@2x.png', products: [] },
{ id: 'washer', name: '洗衣机', icon: '/src/assets/icons/icon_washing-machine-02@2x.png', products: [] },
{ id: 'heater', name: '热水器', icon: '/src/assets/icons/icon_Water-heater-02@2x.png', products: [] }
]
</script>

<template>
<div class="hot-product">
<div class="container">
<HotProductHeader title="热销产品" slogan="核心科技 品质精选" />
<div class="hot-product__category">
<el-tabs v-model="activeTab" class="hot-product__tabs">
<el-tab-pane v-for="category in categories" :key="category.id" :name="category.id">
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</template>

3.2 使用 #label 插槽自定义内容
默认的 ElTabs 只显示文字标题。要实现“图标+文字”的复杂结构,我们需要使用它的 #label 插槽。

“务实的构建者”思路: “默认效果不满足需求?我应该去查阅 Element Plus 关于 Tabs 组件的文档,看看它是否提供了自定义标题的 API。” —— 很快,你就会在文档的“插槽”部分找到 #label

我们来更新 <template> 以使用这个插槽:

1
2
3
4
5
6
7
8
9
10
11
12
<el-tabs v-model="activeTab" class="hot-product__tabs">
<el-tab-pane v-for="category in categories" :key="category.id" :name="category.id">
<template #label>
<div class="hot-product__tab-label">
<div class="hot-product__tab-icon">
<img :src="category.icon" :alt="category.name" />
</div>
<span class="hot-product__tab-text">{{ category.name }}</span>
</div>
</template>
</el-tab-pane>
</el-tabs>

3.3 使用 :deep() 深度定制样式
现在结构对了,但样式还是 Element Plus 默认的。为了匹配设计稿,我们需要覆盖它的内部样式。

“务实的构建者”思路: “我在 <style scoped> 里写的 .el-tabs__item 样式不生效。我知道 scoped 会隔离样式,而 .el-tabs__item 是子组件的内部元素。因此,我需要使用 Vue 提供的 :deep() 伪类来‘穿透’这个隔离。”

我们来补全 <style> 部分,完成对 ElTabs 的美化。

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
<style lang="scss" scoped>
.hot-product {
padding: 32px 0;
background: #fff;

.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}


&__category {
margin-top: 40px;
}

// Element Plus 标签组件样式覆盖
// Element Plus Tabs 样式覆盖
&__tabs {
:deep(.el-tabs__header) { margin-bottom: 40px; }
:deep(.el-tabs__nav-wrap) { &::after { display: none; } }
:deep(.el-tabs__nav) { display: flex; width: 100%; border-bottom: 1px solid $borderColor; }
:deep(.el-tabs__active-bar) { display: none; }
:deep(.el-tabs__item) {
flex: 1;
height: 130px;
padding: 0;
position: relative;
&.is-active, &:hover {
&::after {
content: '';
position: absolute;
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 2px;
background: $GLColor;
}
.hot-product__tab-text { font-weight: 600; color: #111; }
}
}
}

&__tab-label {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}

&__tab-icon {
width: 64px;
height: 64px;
margin-bottom: 18px;

img {
width: 100%;
height: 100%;
object-fit: contain;
}
}

&__tab-text {
font-size: 18px;
font-weight: 400;
color: #333;
line-height: 27px;
}


}
</style>

2.8.3 构建布局容器 (HotProductContent.vue) 与最终组装

本节目标: 我们将创建一个专门负责 布局 的子组件 HotProductContent.vue。您将深入学习如何运用 CSS Grid 来实现复杂的非对称网格。最后,我们将所有“零件” (HotProductHeader, ElTabs, HotProductContent) 在主组件 HomeHotProduct.vue 中完成组装,得到 2 完整的 静态 成品。


1. 创建布局容器组件 (HotProductContent.vue)

遵循“单一职责原则”,HomeHotProduct.vue 负责管理 Tabs 和数据状态,而商品列表的 具体排列方式 这个纯视觉任务,应该交给一个专门的子组件来处理。

文件路径: src/views/Home/components/components/HotProductContent.vue
任务目标: 创建一个接收 products 数组作为 prop 的“布局”组件,其唯一职责就是使用 CSS Grid 和我们之前创建的 ProductCard.vue,将商品数据渲染为非对称网格。

1.1 完整代码实现

这个组件的核心在于它的 <style> 部分,即 CSS Grid 的具体实现。

请在 src/views/Home/components/components/ 目录下创建 HotProductContent.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
<script setup lang="ts">
import { Product } from '@/types/category'
import ProductCard from '@/components/ProductCard.vue'

interface Props {
products: Product[]
showMask?: boolean
showInfo?: boolean
}

interface Emits {
(e: 'product-click', product: Product): void
(e: 'product-hover', product: Product): void
(e: 'product-button-click', product: Product): void
}

const props = withDefaults(defineProps<Props>(), {
showMask: true,
showInfo: true
})

const emit = defineEmits<Emits>()

const handleProductClick = (product: Product) => {
emit('product-click', product)
}

const handleProductHover = (product: Product) => {
emit('product-hover', product)
}

const handleProductButtonClick = (product: Product) => {
emit('product-button-click', product)
}
</script>

<template>
<div class="hot-product-content">
<div class="hot-product__grid">
<div v-for="(product, index) in products" :key="product.id" :class="[
'hot-product__item',
`hot-product__item--${product.type}`,
`hot-product__item--${index + 1}`
]">
<!-- 使用全局 ProductCard 组件 -->
<ProductCard :product="product" :index="index" :show-mask="showMask" :show-info="showInfo"
@click="handleProductClick" @hover="handleProductHover" @button-click="handleProductButtonClick" />
</div>
</div>
</div>
</template>


<style lang="scss" scoped>
// 产品网格布局
.hot-product__grid {
display: grid;
grid-template-columns: repeat(4, 283px);
grid-template-rows: repeat(2, 283px);
gap: 10px;
padding: 3px;
justify-content: center;
}

.hot-product__item {
position: relative;
overflow: hidden;

// 大型项目(左上角)
&--large {
grid-row: span 2;
}

// 普通项目(顶部中间、右侧)
&--normal {
grid-row: span 1;
}

// 高项目(右侧,跨2行)
&--tall {
grid-row: span 2;
}

// 宽项目(底部,跨2列)
&--wide {
grid-column: span 2;
width: 576px;
}

// 特定位置
&--1 {
// 大型项目
grid-column: 1;
grid-row: 1 / 3;
width: 283px;
height: 576px;
}

&--2 {
// 普通项目1
grid-column: 2;
grid-row: 1;
width: 283px;
height: 283px;
}

&--3 {
// 普通项目2
grid-column: 3;
grid-row: 1;
width: 283px;
height: 283px;
}

&--4 {
// 宽项目
grid-column: 2 / 4;
grid-row: 2;
width: 576px;
height: 283px;
}

&--5 {
// 高项目
grid-column: 4;
grid-row: 1 / 3;
width: 283px;
height: 576px;
}
}
</style>

CSS Grid 布局解读:

  • display: grid: 将容器声明为网格布局。
  • grid-template-columns: repeat(4, 283px): 定义了网格有 4 列,每列宽度为 283px。
  • grid-template-rows: repeat(2, 283px): 定义了网格有 2 行,每行高度为 283px。
  • gap: 10px: 定义了网格项之间的间距。
  • grid-row: span 2 / grid-column: span 2: 让一个网格项占据两行或两列。
  • grid-column: 1 / 3: 让一个网格项从第一条列网格线开始,到第三条列网格线结束,即占据第 1、2 列。
  • 核心: 我们通过 :class 动态绑定了来自数据的 typeindex,CSS 再利用这些类名,将每个网格项精确地“安放”到预设的网格位置上,从而实现了这种复杂的非对称布局。

2. 在主组件 (HomeHotProduct.vue) 中完成最终组装

现在,我们所有的“零件”都已备齐,是时候在“总装车间” HomeHotProduct.vue 中将它们组合起来了。

2.1 更新 <script setup>

我们需要引入新创建的 HotProductContent 组件,并添加一个计算属性,用于根据当前激活的 Tab 筛选出需要展示的商品列表。

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
<script setup lang="ts">
import { ref, computed } from 'vue' // 引入 computed
import type { Product } from '@/types/category'
import HotProductHeader from './components/HotProductHeader.vue'
import HotProductContent from './components/HotProductContent.vue' // 1. 引入内容组件

const activeTab = ref('new')

// 2. 完整的本地静态数据
const categories = [
{
id: 'new',
name: '新品',
icon: '/src/assets/icons/icon_New product-02@2x.png',
products: [
{ id: 1, name: '自然又自在', desc: '格力·至尊 家居生活新中心', picture: '/images/new/product1.jpg', type: 'large' },
{ id: 2, name: '循环风扇', desc: '臻品工艺 拂动盛夏', picture: '/images/new/product2.jpg', type: 'normal' },
{ id: 3, name: '空气净化器', desc: '森林级空气管家', picture: '/images/new/product3.jpg', type: 'normal' },
{ id: 4, name: '热泵洗衣机', desc: '37℃烘干不伤衣', picture: '/images/new/product4.jpg', type: 'wide' },
{ id: 5, name: '晶弘魔法冰箱', desc: '鲜嫩两星期 轻触一刀切', picture: '/images/new/product5.jpg', type: 'tall' }
]
},
{ id: 'home-ac', name: '家用空调', icon: '/src/assets/icons/icon_Air-Conditioner-02@2x.png', products: [/* ...家用空调的 5 个商品... */] },
// ... 其他分类及其商品 ...
]

// 3. 计算属性:根据 activeTab 筛选出对应的商品列表
const activeProducts = computed(() => {
const activeCategory = categories.find(cat => cat.id === activeTab.value)
return activeCategory?.products || []
})

// 4. 事件处理函数
const handleProductClick = (product: Product) => {
console.log('在 HomeHotProduct 组件中捕获到点击事件:', product)
}
const handleProductButtonClick = (product: Product) => {
console.log('在 HomeHotProduct 组件中捕获到按钮点击事件:', product)
}
</script>

2.2 更新 <template>

最后,在 ElTabPane 内部使用 HotProductContent 组件,并将计算属性和事件处理函数绑定上去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div class="hot-product">
<div class="container">
<HotProductHeader title="热销产品" slogan="核心科技 品质精选" />
<div class="hot-product__category">
<el-tabs v-model="activeTab" class="hot-product__tabs">
<el-tab-pane v-for="category in categories" :key="category.id" :name="category.id">
<template #label>
</template>
<HotProductContent
:products="activeProducts"
@product-click="handleProductClick"
@product-button-click="handleProductButtonClick" />
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</template>

2.8.4 接入真实 API 数据 (动态化)

本节目标: 我们将移除组件内的本地静态数据,使用 TanStack Query 从我们已有的 /categories 接口获取数据,并利用 ElSkeleton 组件添加优雅的加载状态,最终完成一个企业级的、完全由后端数据驱动的动态组件。


1. 设计思路:为何选择 TanStack Query

我们完全可以用 onMounted 钩子配合 axios 来获取数据,但“务实的构建者”会寻求更专业的解决方案。TanStack Query 正是为此而生。

技术选型
动态化改造前

好了,要从后端拿数据了。最直接的办法就是在 onMountedawait getCategoryAPI(),然后把结果赋给一个 ref,对吗?

完全正确,这是 Vue 开发的“标准答案”。但它需要我们手动管理很多状态,比如 isLoadingisError 等。

有什么更高效的办法吗?

这就是我们引入 TanStack Query 的原因。你只需要告诉它用哪个 key 缓存数据,以及用哪个函数 (queryFn) 去获取数据,它就会自动处理剩下的所有事:loading 状态、error 状态、数据缓存、甚至重新聚焦窗口时的自动刷新… 它把所有繁琐的异步数据逻辑都封装好了,让我们的组件代码极其纯净。

明白了,这就是“用最好的轮子造更好的车”。我只需要关心“拿数据”这个动作本身,而不用关心拿数据的过程。

2. API 与类型准备

幸运的是,我们之前的工作已经为这一刻铺好了路。

  • API 接口: 我们将直接复用在 2.5 节 为头部导航创建的 getCategoryAPI 函数 (src/apis/layout.ts)。它请求的 /categories 接口返回的正是我们需要的、包含 products 数组的完整分类数据。TanStack Query 的缓存机制甚至可能会让这次请求直接命中缓存,瞬间完成!
  • 类型定义: 请确保 src/types/category.ts 中的 Product 类型定义与我们的 mock-data.json 完全一致,特别是包含了 type 字段。
1
2
3
4
5
6
7
8
// src/types/category.ts (确认)
export interface Product {
id: number;
name: string;
desc: string;
picture: string;
type: 'large' | 'normal' | 'wide' | 'tall'; // 使用联合类型更精确
}

3. 使用 TanStack Query 重构 HomeHot.vue

这是本节的核心,我们将对 HomeHotProduct.vue<script setup> 部分进行“大换血”。

3.1 引入 useQuery 并移除静态数据

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
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Product } from '@/types/category'
import HotProductHeader from './components/HotProductHeader.vue'
import HotProductContent from './components/HotProductContent.vue'

// 1. 引入 TanStack Query 和 API
import { useQuery } from '@tanstack/vue-query'
import { getCategoryAPI } from '@/apis/layout'

const activeTab = ref('new')

// 2. 使用 useQuery 获取数据
const { data: categories, isLoading } = useQuery({
queryKey: ['categories'],
queryFn: getCategoryAPI,
select: (data) => data.result // 关键:通过 select 转换,直接获取 result 数组
})

// 3. 移除本地的静态 categories 数组
// const categories = [ ... ] // <--- 整段删除

// 计算属性现在依赖于 useQuery 返回的数据
const activeProducts = computed(() => {
// 当 categories.value 存在时才进行查找
return categories.value?.find(cat => cat.id === activeTab.value)?.products || []
})

// 事件处理函数保持不变
const handleProductClick = (product: Product) => {
console.log('点击产品:', product)
}
const handleProductButtonClick = (product: Product) => {
console.log('点击了解更多按钮:', product)
}
</script>

useQuery 解读:

  • queryKey: ['categories']: 这是该份数据在 TanStack Query 缓存中的唯一标识。
  • queryFn: getCategoryAPI: 指定了获取数据的异步函数。
  • select: (data) => data.result: 这是一个非常有用的转换器。我们的 API 返回的是 { code, msg, result } 结构,通过 select,我们可以直接将 result 属性提取出来,赋值给 data (也就是我们重命名的 categories),让后续使用更方便。
  • const { data: categories, isLoading }: 我们从 useQuery 的返回结果中解构出 data 并重命名为 categories,同时解构出 isLoading 状态,用于控制加载效果。

2.9 模块提交与总结

至此,我们已经完成了 vue3-webShop 项目的通用布局和核心首页的开发。我们不仅构建了静态骨架,还通过 PiniaTanStack Query 成功注入了动态数据,为应用打下了坚实的业务基础。现在,是时候将我们本模块的成果提交到版本库了。

当前任务: 2.9 - 模块成果提交
任务目标: 将模块二中完成的所有通用布局与首页功能,作为一个完整的特性提交到 Git 仓库。

命令行操作

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

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

    1
    git add .
  2. 提交代码,并附上符合“约定式提交”规范的 message:

    1
    git commit -m "feat(layout, home): build main layout and dynamic homepage"

    Commit Message 解读:

    • feat: 表示这是一个新功能 (feature) 的提交。
    • (layout, home): 指明了本次提交影响的主要范围是“布局”和“首页”模块。
    • build main layout and dynamic homepage: 简明扼要地描述了我们完成的具体工作:构建了主布局和动态化的首页。

提交成功后,您的项目就有了一个清晰的、代表“首页开发完成”的历史节点。我们已经准备好进入下一个模块,继续构建登录与用户认证功能。