Vue 生态 (实战):实战篇:从零搭建企业级 Vue3 电商项目 —— 掌握 Element Plus + TanStack Query 核心技术

模块零:项目概述、技术选型与架构蓝图

本章概述: 本章旨在为 vue3-webShop 实战项目建立一个清晰的宏观认知。我们将明确项目的总体目标、核心学习路径,并详细阐述其技术选型与分层架构设计,为后续所有开发工作奠定坚实的理论基础。


0.1 项目定义与核心学习目标

项目定义: vue3-webShop 是一个功能完备、对标企业级标准的全栈模拟电商前台应用。它涵盖了从项目初始化、用户认证、商品展示、购物车交互到订单生成的完整业务闭环。

本次实战的核心目标,并非简单地复刻功能,而是通过该项目,深入掌握构建现代化、大型、可维护前端应用的 核心能力。具体学习目标如下:

  • 精通企业级 UI 解决方案: 深度掌握 Element Plus 组件库的应用,重点学习其在复杂业务场景下的使用技巧与 主题化定制 方案。
  • 掌握现代状态管理架构: 学习并实践 TanStack Query (服务端状态) + Pinia (客户端状态) 的分层管理模式,彻底分离异步数据与 UI 状态,提升应用的可预测性和健壮性。
  • 构建工业级工程化体系: 掌握从零开始搭建一个包含自动化代码检查、格式化、提交规范验证的完整开发环境与工作流。
  • 实践可扩展的架构模式: 学习并应用“功能内聚”的目录组织方式、高内聚低耦合的组件设计原则,以及组合式函数(Composables)的最佳实践。

0.2 技术选型与分层架构设计

一个项目的技术选型,直接决定了其开发效率、性能上限和长期可维护性。我们采用了一套经过业界广泛验证的现代化技术栈,并遵循清晰的分层架构设计。

架构层级核心技术选型理由与核心职责
UI 表现层Element Plus提供一套统一、高质量的 UI 组件,极大提升开发效率。其丰富的组件生态和强大的功能(如表单校验、复杂表格)能满足绝大多数企业级需求。我们将重点实践其 基于 SCSS 变量的深度定制能力
状态管理层TanStack Query服务端状态核心。专门用于管理与服务器的异步数据交互。它提供了缓存、后台同步、请求去重等强大功能,将组件从繁琐的 loading/error 状态管理中解放出来,是现代 Web 应用处理异步数据的最佳实践。
Pinia客户端状态核心。Vue 官方的状态管理器,用于管理与 UI 强相关或需跨组件共享的、非持久化在后端的纯前端状态(如:用户登录信息、购物车数据)。
核心框架层Vue 3 / Vite 5基于组合式 API (<script setup>) 进行开发,提供更灵活、更高效的逻辑组织与复用能力。Vite 则保证了极致的开发服务器性能和构建速度。
工程化基建TypeScript为项目提供静态类型系统,是构建大型、可维护应用,减少运行时错误的基石。
SCSS成熟的 CSS 预处理器,其变量、嵌套、混入等特性是实现代码复用和主题化定制的必要工具。
ESLint / Prettier保证代码在质量规范和风格上的一致性,是团队协作的必备工具。
Husky / lint-staged将代码规范检查前置到 Git 提交阶段,从流程上保证代码仓库的纯净度。

为使开发过程能完全聚焦于前端工程化,本项目将采用 json-server 来提供稳定、可预测的模拟 RESTful API。这种方式实现了彻底的前后端分离,是现代前端“接口先行”开发模式的典型实践。


0.3 项目模块化开发路线图

我们将采用模块化的方式,循序渐进地完成整个项目的开发。以下是本次实战的完整开发路线图:

  1. 模块一:工程化环境搭建
    • 目标: 初始化项目,集成所有核心依赖,并建立一套完整的自动化代码质量保障体系。
  2. 模块二:首页与通用布局
    • 目标: 使用 Element Plus 构建应用的整体布局,并开发包含轮播图、商品分类和推荐板块的首页。
  3. 模块三:登录与用户认证
    • 目标: 实现完整的用户认证流程,精通 Element Plus 的表单校验与 TanStack Query 的 useMutation 应用。
  4. 模块四:商品分类与列表
    • 目标: 开发二级分类页面,实现面包屑导航,并通过 TanStack Query 高效获取和渲染筛选后的商品列表。
  5. 模块五:购物车与商品详情核心功能
    • 目标: 构建完整的购物车模块,深入实践 Pinia 与 Element Plus 复杂组件(如 Table, InputNumber)的数据联动。

总结: 本次实战将是一次从零到一的、完整的企业级前端项目构建之旅。我们不仅会学习如何使用最新的技术栈,更重要的是,我们将学习如何像架构师一样思考,构建一个结构清晰、代码健壮、易于维护和扩展的现代化 Web 应用。


模块一:专业基石 - 构筑自动化与企业级的开发环境

本模块任务清单

场景一:技术启动会
项目启动第一天

本模块将从零开始,构建一个功能完备、高度规范化的 Vue 3 企业级开发环境。我们将集成并深度配置所有核心依赖,建立一套全自动化的代码质量保障与提交体系。

通过以下 9 个核心任务,您将掌握构建企业级 Vue 3 开发环境的完整流程:

任务模块任务名称描述
项目初始化项目模板创建使用 pnpmVite 创建纯净的 Vue + TypeScript 项目模板。
代码规范体系ESLint & Prettier 集成集成 ESLintPrettier,采用 eslint.config.js 范式,统一代码质量与风格。
路径别名配置开发效率基础设施配置路径别名,为后续章节的模块引用提供基础支持。
UI 组件库集成Element Plus 按需导入安装 Element Plus,利用 unplugin-vue-components 实现组件按需自动导入。
国际化配置Element Plus 中文化将 Element Plus 默认语言从英语切换为简体中文。
SCSS 企业级架构样式系统构建集成 SCSS,建立企业级的样式架构和 Element Plus 主题定制方案。
核心服务集成基础服务安装与配置完成 TanStack Query, Pinia, Vue RouterAxios 的基础安装与初始化配置。
API 自动导入开发效率优化配置 API 自动导入,消除重复的 import 语句,提升编码效率。
自动化提交流程Husky & lint-staged配置 Huskylint-staged,在 Git 提交流程中强制执行代码检查。

1.1 项目初始化

我们从最基础的一步开始:创建一个全新的项目。

当前任务: 1.1 - 项目初始化

任务目标: 使用业界推荐的包管理器 pnpm 和构建工具 Vite,快速生成一个标准的 Vue 3 + TypeScript 项目骨架。

1.1.1 设计思路与技术选型

在包管理器方面,我们优先选择 pnpm,而非 npmyarnpnpm 的核心优势体现在以下几个方面:

  • 极致的磁盘空间效率: pnpm 采用内容寻址和硬链接技术,确保相同版本的依赖在磁盘上只存储一份,极大地节省了存储空间。
  • 闪电般的安装速度: 凭借对本地缓存的优先利用,pnpm 在依赖安装速度上显著优于其竞争者。
  • 严格的依赖管理: pnpm 的非扁平化 node_modules 结构从根本上解决了 “幽灵依赖” 问题,从而增强了项目的稳定性和可维护性。

在构建工具方面,Vite 是我们现代 Vue 项目开发的标准配置。其基于原生 ES 模块的开发服务器,提供了无与伦比的冷启动速度和热模块更新(HMR)性能,极大地提升了开发体验。

1.1.2 命令行操作

首先,请确保您的开发环境中已全局安装 pnpm。如果尚未安装,可以通过 npm 执行以下命令进行安装:

1
npm install -g pnpm

接下来,打开终端并导航至您的工作目录。执行以下命令来启动项目创建流程:

1
pnpm create vite

Vite 的脚手架工具将引导您完成一系列项目配置选择。请按照以下配置项进行选择:

  • Project name: vue3-webShop
  • Select a framework: Vue
  • Select a variant: TypeScript

完成上述选择后,Vite 将在 /path/to/your/workspace/vue3-webShop 路径下完成项目骨架的搭建。随后,按照提示进入项目目录并安装所有依赖:

1
2
cd vue3-webShop
pnpm install

1.1.3 成果预览:初始项目结构

执行完以上命令后,您将获得一个纯净且标准的 Vite + Vue 3 项目结构,其目录概览如下:

1
2
3
4
5
6
7
8
9
10
11
12
# vue3-webShop/
├── public/
├── src/
│ ├── assets/
│ ├── components/
│ ├── App.vue
│ └── main.ts
├── .gitignore
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts

至此,项目的 “毛坯房” 已经搭建完毕。这是一个坚实且干净的起点,我们将在其之上,逐步添砖加瓦,构筑我们的应用大厦。


1.2 代码规范体系

在团队协作中,代码不仅是实现功能的工具,更是沟通的媒介。一套统一、自动化的代码规范体系,是保障项目长期可维护性、提升团队协作效率的基石。

当前任务: 1.2 - 代码规范体系
任务目标: 为项目集成 ESLint (代码质量检查) 和 Prettier (代码风格格式化),并采用最新的 eslint.config.js 范式,建立一套专业的、自动化的代码规范标准。

1.2.1 设计思路:质量与风格的分离

在配置之前,我们必须理解两个核心工具的职责边界:

  • ESLint (代码质量卫士): 它的核心职责是 发现代码中的潜在错误和不合理的写法。例如,是否存在未被使用的变量、是否在 switch 语句中缺少 break 等。它关心的是代码的 正确性健壮性
  • Prettier (代码风格造型师): 它的职责非常纯粹,即 统一代码的格式。例如,是使用单引号还是双引号、行尾是否需要分号、代码行的最大宽度等。它只关心代码的 外观,不关心其逻辑。

我们的策略是让它们各司其职,并通过配置让它们完美协作:ESLint 负责修复代码质量问题,Prettier 负责最终的格式化,且 Prettier 的优先级更高,以避免两者在风格规则上的冲突。

1.2.2 核心依赖安装

现在,我们将为项目安装所有必需的 “纪律委员”。请在项目根目录下执行以下命令:

1
pnpm add -D @eslint/js eslint eslint-config-prettier eslint-plugin-vue globals prettier typescript-eslint

依赖解读:

  • eslint & @eslint/js: ESLint 本体及其官方核心规则集。
  • typescript-eslint: 用于让 ESLint 理解和校验 TypeScript 语法的核心插件。
  • eslint-plugin-vue: 专为 .vue 单文件组件量身打造的规则插件。
  • eslint-config-prettier: 关键!用于关闭所有与 Prettier 冲突的 ESLint 规则,确保 Prettier 拥有最终的格式化决定权。
  • prettier: Prettier 本体。
  • globals: 用于预设浏览器、Node.js 等环境的全局变量,避免 ESLint 报错。

1.2.3 现代化 ESLint 配置

ESLint 的配置已全面转向基于 ES Module 的 eslint.config.js 文件。这种新范式提供了前所未有的灵活性。

现在,我们在项目根目录创建 eslint.config.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
// eslint.config.js
import globals from "globals";
import eslintJs from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintPluginVue from "eslint-plugin-vue";
import eslintConfigPrettier from "eslint-config-prettier";

export default tseslint.config(
// 全局忽略配置
{
ignores: ["dist", "node_modules", "*.config.js", "public", ".DS_Store"],
},
// 基础配置,应用于所有文件
{
languageOptions: {
globals: {
...globals.browser,
...globals.es2021,
},
},
},
// ESLint 官方推荐规则
eslintJs.configs.recommended,
// TypeScript ESLint 推荐规则
...tseslint.configs.recommended,
// Vue 插件推荐规则 (针对 Vue 3)
...eslintPluginVue.configs["flat/recommended"],
// Prettier 配置,必须放在最后
eslintConfigPrettier,
// 自定义规则配置
{
files: ["**/*.vue"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
},
},
rules: {
"vue/multi-word-component-names": "off",
"vue/html-self-closing": [
"error",
{
html: {
void: "always",
normal: "never",
component: "always",
},
},
],
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
},
}
);

配置解读:

  1. ignores: 全局忽略配置。我们明确告诉 ESLint 不要检查构建产物、依赖、配置文件等,以提升检查效率并避免误报。
  2. languageOptions.globals: 全局变量配置。我们引入了 globals 包中预设的浏览器和 ES2021 环境的全局变量,这样在代码中使用 windowPromise 等就不会被 ESLint 判为未定义。
  3. eslintJs.configs.recommended: 启用 ESLint 官方的核心推荐规则。
  4. ...tseslint.configs.recommended: 启用 typescript-eslint 的推荐规则集,用于检查 TypeScript 代码。
  5. ...eslintPluginVue.configs["flat/recommended"]: 启用 eslint-plugin-vue 针对 Vue 3 的推荐规则集。
  6. eslintConfigPrettier: 关键步骤。此配置会禁用所有与 Prettier 格式化功能冲突的 ESLint 规则。它必须放在配置数组的靠后位置,以确保它能覆盖之前所有规则集中的样式规则。
  7. 自定义规则: 这是我们项目的专属规则区。
    • files: ["**/*.vue"] 指定了这些规则仅对 .vue 文件生效。
    • parserOptions: 为 Vue 文件的 <script> 部分指定 TypeScript 解析器。
    • rules:
      • 'vue/multi-word-component-names': 'off': 关闭了组件名必须为多词的规则,在某些场景下(如根组件 App.vue)可以更灵活。
      • 'vue/html-self-closing': 配置了 HTML 元素的自闭合风格,增强代码一致性。
      • 'no-console', 'no-debugger': 设置了在生产环境下(process.env.NODE_ENV === 'production')禁止使用 consoledebugger,这是非常重要的生产环境最佳实践。

1.3 路径别名配置

为了在后续开发中方便地引用 src 目录下的文件,避免使用复杂的相对路径,我们需要先设置路径别名。这是后续章节正常使用 @/ 前缀的基础配置。

当前任务: 1.3 - 路径别名配置
任务目标: 配置 @ 作为 src 目录的别名,为项目建立简洁的模块引用方式。

1.3.1 设计思路

在大型项目中,文件结构往往很深,使用相对路径会导致这样的引用:

1
2
import { apiCall } from "../../../utils/http";
import UserComponent from "../../../components/User.vue";

通过配置路径别名,我们可以将其简化为:

1
2
import { apiCall } from "@/utils/http";
import UserComponent from "@/components/User.vue";

这不仅让代码更简洁,还避免了在移动文件时需要修改引用路径的问题。

1.3.2 Vite 配置

在项目根目录的 vite.config.ts 文件中添加路径别名配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
// vite.config.ts
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});

1.3.3 TypeScript 支持

为了让 TypeScript 理解这个路径别名,我们需要更新 TypeScript 配置。

方法一:如果您的项目使用单一 tsconfig.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

方法二:如果您的项目使用 tsconfig.app.json (推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// tsconfig.app.json
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,

/* 路径别名配置 */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

修改 TypeScript 配置文件后,请务必通过 VS Code 命令面板 (Ctrl+Shift+P) 执行 TypeScript: Restart TS Server 命令来重启 TypeScript 服务,以确保路径提示生效。

1.3.4 功能测试

配置完成后,在任意 Vue 组件中尝试使用 @/ 前缀:

1
2
3
4
5
6
7
8
9
10
<script setup lang="ts">
// 测试路径别名是否生效
import { ref } from "vue";
// 假设后续章节会创建这个文件
// import { someUtil } from '@/utils/helpers'
</script>

<template>
<div>路径别名配置完成</div>
</template>

如果 VS Code 能够正确提供路径提示和自动补全,说明配置成功。


1.4 UI 组件库与图标库集成

为项目引入一套成熟的 UI 组件库,是现代前端开发的标准实践。它能极大提升开发效率,保证 UI 的一致性和可访问性,让我们能专注于业务逻辑的实现。

当前任务: 1.4 - UI 组件库与图标库集成
任务目标: 为项目安装 Element Plus 及其官方图标库,并利用 unplugin-vue-componentsunplugin-auto-importunplugin-icons 插件,实现组件 和图标 的按需自动导入。

1.4.1 设计思路:按需自动导入

在项目中引入 Element Plus 有两种方式:全局引入和按需导入。

  • 全局引入: 简单直接,但会将所有组件和图标打包,导致最终构建产物体积过大。
  • 按需导入: 只打包代码中实际使用到的组件,是生产环境的最佳实践。

为了实现优雅的按需导入,避免在每个文件中手动 import { ElButton } from 'element-plus'import { Edit } from '@element-plus/icons-vue',我们借助 unplugin 系列插件来自动化这一过程。

1.4.2 核心依赖安装

我们将安装 element-plus 本体、其官方图标库,以及三个实现自动导入的 unplugin 插件。请在项目根目录下执行以下命令:

1
2
3
4
5
# 1. 安装 element-plus 和图标库
pnpm add element-plus @element-plus/icons-vue

# 2. 安装 unplugin 系列插件
pnpm add -D unplugin-vue-components unplugin-auto-import unplugin-icons @iconify-json/ep

依赖解读:

包名核心职责
element-plusElement Plus 组件库本体。
@element-plus/icons-vueElement Plus 的官方图标库,其中每个图标都是一个独立的 Vue 组件。
unplugin-auto-import自动导入 API,如 ElMessage 等 JS 调用方法。
unplugin-vue-components自动导入组件,扫描模板中的标签(如 <el-button>)并自动导入。
unplugin-icons一个强大的图标插件,可以与 unplugin-vue-components 协同工作,实现图标的自动导入。
@iconify-json/epElement Plus 图标的 Iconify 数据包,为 unplugin-icons 提供图标数据。

1.4.3 Vite 配置文件 (vite.config.ts)

安装完依赖后,我们需要在 vite.config.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
// vite.config.ts
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// 1. 引入所需插件
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

// 引入 unplugin-icons
import Icons from "unplugin-icons/vite";
import IconsResolver from "unplugin-icons/resolver";

export default defineConfig({
plugins: [
vue(),
// 2. 配置 unplugin-auto-import
AutoImport({
resolvers: [ElementPlusResolver()],
}),
// 3. 配置 unplugin-vue-components
Components({
resolvers: [
// 自动导入 Element Plus 组件
ElementPlusResolver({ importStyle: "sass" }),
// 自动导入 Element Plus 图标
// 我们约定图标组件的使用方式是 <i-ep-iconName />
IconsResolver({
prefix: "i", // 默认为 i, 可不写
enabledCollections: ["ep"], // ep 是 element-plus 的图标库
}),
],
}),
// 4. 配置 unplugin-icons
Icons({
autoInstall: true,
}),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});

配置解读:

  • IconsResolver: 这是专门用于解析图标的解析器。
    • enabledCollections: ['ep']: 告诉解析器只启用 Element Plus 的图标集 (ep 是其简称)。
  • 使用约定: 我们建立了一个清晰的图标使用约定:所有以 <i-ep- 开头的标签(例如 <i-ep-edit />),都会被自动识别为 Element Plus 的图标并导入。

1.4.4 TypeScript 类型支持 (Volar)

为了让 TypeScript 和 Volar 能够识别自动导入的组件和图标,我们需要确保 tsconfig.jsoninclude 配置包含了插件自动生成的类型声明文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
// tsconfig.json
{
// ...
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"auto-imports.d.ts",
"components.d.ts"
]
// ...
}

修改配置后,你可能需要重启 VS Code 或执行 TypeScript: Restart TS Server 命令来让类型提示生效。

1.4.5 功能测试

配置完成后,我们来验证组件和图标是否都已成功集成。打开 src/App.vue 文件,替换为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts"></script>

<template>
<el-button type="primary">我是按钮</el-button>

<el-button type="success">
<i-ep-edit />
编辑按钮
</el-button>

<div>
<i-ep-success color="green" style="font-size: 2em" />
<i-ep-warning color="orange" style="font-size: 2em" />
<i-ep-circle-close color="red" style="font-size: 2em" />
</div>
</template>

现在,启动开发服务器 (pnpm run dev)。如果你能看到按钮和图标都正常显示,那么恭喜你,自动导入已成功配置!

1.4.6 深度解析:图标的两种用法 (手动 vs 自动)

你可能会有疑问:为什么我们不使用官方文档中常见的 import { Edit } from '@element-plus/icons-vue' 方式,而是用 <i-ep-edit />?这是一个非常好的问题,理解它有助于你掌握现代前端的工程化思想。

开发者对话
配置完成后

等等,我平时用图标都是 import { Phone } from '@element-plus/icons-vue',然后在模板里用 :icon="Phone"。你教的 <i-ep-phone /> 是什么新语法?

架构师

问得好!你说的 手动导入 是 Vue 最经典、最直观的用法。而我们配置的 自动导入,是利用工具链提升效率的 工程化最佳实践。两者都能用,但后者能让你的代码更简洁。

有什么区别?

架构师

手动导入,你是在 运行时 告诉 Vue,“Phone 这个变量是一个组件”。而自动导入,是插件在 编译时 就帮你把 <i-ep-phone /> 这个 “暗号” 直接替换成了真正的组件和导入语句。

我明白了!所以自动导入省去了我在 <script> 里写一大堆 import 的麻烦,想用哪个图标,直接在模板里写 “暗号” 就行了。

架构师

完全正确!这就是现代前端工程化的魅力所在——把重复的、机械的工作交给工具,让开发者专注于创造。

方法 A: 手动导入 (传统方式)

这是 Element Plus 官方文档中最常见的用法,依赖关系清晰。

  • 代码示例:
    1
    2
    3
    4
    5
    6
    <script setup lang="ts">
    import { Phone } from "@element-plus/icons-vue";
    </script>
    <template>
    <el-link :icon="Phone">在线客服</el-link>
    </template>
  • 优点: 依赖关系明确,无需额外配置。
  • 缺点: 当使用大量图标时,<script> 部分会变得非常冗长。

方法 B: 自动导入 (工程化实践)

这是我们通过插件配置实现的用法,开发体验极致高效。

  • 代码示例:
    1
    2
    3
    4
    <template>
    <!-- 无需在 script 中做任何事 -->
    <el-button type="success"> <i-ep-edit /> 编辑按钮 </el-button>
    </template>
  • 优点: <script> 保持干净,开发效率极高,代码更整洁。
  • 缺点: 需要前期配置,对于不熟悉配置的人来说有 “魔法” 成分。
特性手动导入 (传统方式)自动导入 (工程化实践)
核心思想运行时,开发者手动管理依赖编译时,插件自动注入依赖
代码整洁度<script> 部分较长,依赖明确<script> 部分干净,模板即声明
开发效率较低,需要手动导入极高,开箱即用
推荐实践偶尔需要将图标作为变量传递时使用现代前端工程化的首选方案,适用于绝大部分场景

结论:
在本教程中,我们将 优先使用自动导入 的方式,因为它极大地提升了开发体验,是构建大型企业级项目的首选。理解这两种方式的差异,将让你对现代前端工具链有更深刻的认识。


1.5 国际化 (i18n) 配置

Element Plus 组件库默认的语言是英语。对于面向中文用户的项目,我们需要进行国际化配置,将提示文字、日期格式等转换为中文,以提供符合用户习惯的体验。

当前任务: 1.5 - 国际化 (i18n) 配置
任务目标: 将 Element Plus 组件的默认语言从英语(en)切换为简体中文(zh-cn)。

1.5.1 设计思路

Element Plus 提供了两种主流的国际化配置方案:

  1. 全局配置: 在应用入口文件 (main.ts) 中,通过 app.use 传入全局配置对象。这是最常用、最直接的方式,一次配置,全局生效。
  2. ElConfigProvider 组件: 使用 ElConfigProvider 组件包裹根组件或部分组件树,可以实现全局或局部的语言配置。

对于我们整个应用都需要使用中文的场景,全局配置 是最佳选择。

1.5.2 全局配置实现

我们将修改 main.ts 文件,导入 Element Plus 的中文语言包并进行全局注册。

现在,让我们整合这段逻辑到 main.ts 文件中。如果架构师的 main.ts 中已经有 app.use(ElementPlus),请将其替换为带有配置对象的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/main.ts

import { createApp } from "vue";
import App from "./App.vue";

import ElementPlus from "element-plus";
// 1. 导入 Element Plus 的 CSS 样式文件
import "element-plus/dist/index.css";
// 2. 导入 Element Plus 的中文语言包
// 注意:新版本 Element Plus 的语言包路径可能有所变化,请以实际为准
// .mjs 后缀表示这是一个 ES Module
import zhCn from "element-plus/es/locale/lang/zh-cn";

const app = createApp(App);

// 3. 全局注册 Element Plus 并配置国际化
app.use(ElementPlus, {
locale: zhCn,
});

app.mount("#app");

配置解读:

  • 我们从 element-plus/dist/locale/ 目录下导入了 zh-cn.mjs,这是一个包含了所有组件中文翻译的对象。
  • 在调用 app.use(ElementPlus) 时,我们传入了第二个参数,一个配置对象 { locale: zhCn }。Element Plus 内部会接收这个对象,并将 zhCn 设为默认语言包。

1.5.3 功能测试

如何验证国际化配置是否生效?我们可以使用一个包含大量文本的组件,例如日期选择器 (ElDatePicker)。

修改 src/App.vue 进行测试:

1
2
3
4
5
6
7
8
9
10
11
<template>
<div class="test-container">
<el-date-picker type="date" placeholder="请选择日期" />
</div>
</template>

<style scoped>
.test-container {
padding: 20px;
}
</style>

现在,运行项目。当你点击日期选择框时,弹出的面板中的 “一月”、“周一”、“今天” 等文字如果都显示为中文,则证明我们的国际化配置已成功生效。

现在,运行项目。当你点击日期选择框时,弹出的面板中的 “一月”、“周一”、“今天” 等文字如果都显示为中文,则证明我们的国际化配置已成功生效。


1.6 SCSS 企业级架构与主题定制

在企业级项目中,样式的可维护性、可扩展性和一致性至关重要。本节,我们将摒弃零散的样式文件,构建一套专业、分层的 SCSS 架构。我们将学习如何集成并定制 Element Plus,同时建立一套包含全局变量、基础规范、混入(Mixins)的完整体系,并通过一个统一的入口文件进行管理,最终由 Vite 实现高效的全局注入。

当前任务: 1.6 - 构建企业级 SCSS 架构
任务目标: 建立一套专业的、可扩展的 SCSS 文件结构。通过 Vite additionalData 全局注入编译时工具(变量、混入),并通过 main.ts 导入唯一的全局样式文件,实现最高效的样式管理方案。

1.6.1 设计思路:全局样式与编译工具的分离

一个顶级的样式架构必须清晰地分离两个概念:

  1. 全局样式表 (Global Stylesheet): 这是应用的基础外观,包含 CSS 重置、基础排版 (body, a 标签等) 和 Element Plus 的定制化样式。这些样式应该被编译成一个单一的 CSS 文件,并在应用入口 (main.ts) 加载且仅加载一次
  2. 编译时工具 (Compile-Time Tools): 这是我们在编写组件独有样式时需要的设计令牌和代码片段,如 $GLColor 变量和 flex-center 混入。这些工具本身不产生 CSS,它们需要在 每个组件的 <style> 块中都可用,以便我们遵循设计规范。

我们将通过 main.scss 文件来组织 全局样式表,并通过 Vite 的 additionalData 配置来全局提供 编译时工具

1.6.2 依赖安装与文件结构创建

  1. 安装 SCSS 编译器与重置库:

    1
    pnpm add -D sass scss-reset
  2. 创建全新的样式文件结构:
    src/ 目录下创建 styles 文件夹,并建立以下专业结构:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    src/
    └── styles/
    ├── abstracts/
    │ ├── _variables.scss # 全局变量
    │ ├── _mixins.scss # 全局混入
    │ └── _utilities.scss # 工具类
    ├── base/
    │ └── _index.scss # 基础样式规范
    ├── element/
    │ └── _index.scss # Element Plus 变量覆盖
    └── main.scss # 唯一的全局样式总入口

1.6.3 编写抽象层 (Abstracts)

这是我们的 “工具箱”,包含所有不直接输出 CSS 的变量和混入。

1. _variables.scss

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

// 品牌色
$GLColor: #0155b2;

// 功能色
$sucColor: #1dc779;
$warnColor: #ffb302;
$priceColor: #cf4444; // 也用于危险/错误状态
$helpColor: #e26237;

// 中性色
$textColor: #333;
$textColor-secondary: #666;
$borderColor: #e4e4e4;
$bgColor: #f5f5f5;

// 其他
$borderRadius: 4px;
$transition-duration: 0.2s;

2. _mixins.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/styles/abstracts/_mixins.scss

// Flexbox 快速居中
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}

// 文本溢出截断
@mixin truncate-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

3. _utilities.scss

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

// 工具类混入
@mixin utilities {
.text-center {
text-align: center;
}

.text-left {
text-align: left;
}

.text-right {
text-align: right;
}
}

1.6.4 编写会输出 CSS 的样式层

这两个层级的文件会实际产出 CSS,它们需要显式地导入自己所依赖的变量。

1. base/_index.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
// src/styles/base/_index.scss

// 导入变量
@use "../abstracts/variables" as *;

// 1. 根设置: 设定 rem 计算基准和全局盒模型
html {
font-size: 62.5%; // 1rem = 10px
box-sizing: border-box;
}

*,
*::before,
*::after {
box-sizing: inherit;
}

// 2. Body 基础排版
body {
font-size: 1.6rem; // 默认 16px
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
color: $textColor;
background-color: $bgColor;
-webkit-font-smoothing: antialiased;
}

// 3. 基础链接规范
a {
color: $GLColor;
text-decoration: none;
transition: color $transition-duration;
&:hover {
color: lighten($GLColor, 10%);
}
}

2. element/_index.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
// src/styles/element/_index.scss

// 显式导入依赖的变量
@use "../abstracts/variables" as *;

@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
"primary": (
"base": $GLColor,
),
"success": (
"base": $sucColor,
),
"warning": (
"base": $warnColor,
),
"danger": (
"base": $priceColor,
),
"error": (
"base": $priceColor,
),
)
);

1.6.5 编写全局样式入口 main.scss

此文件是 全局样式表的指挥中心,它按顺序编排所有会产出 CSS 的样式模块。

1
2
3
4
5
6
7
8
9
10
// src/styles/main.scss

// 1. 第三方库: 如样式重置库
@use "scss-reset";

// 2. 基础层: HTML 元素的全局样式
@use "base";

// 3. 框架覆盖层: 对 Element Plus 的样式定制
@use "element";

1.6.6 理解工作流:最佳注入策略

开发者日记
开发中

架构师,我们现在有了 main.scss 总入口,还有 _variables.scss 这些工具文件。我们应该如何在 Vite 中配置,才能达到最佳的性能和开发体验?

这是一个关键问题。最粗暴的方法是在 additionalData 里注入 main.scss,但这会导致 scss-resetbase 这些全局样式被重复打包进每一个组件的 CSS 中,造成代码冗余。

那该怎么办?

采用 分离策略。Vite 的 additionalData 只用来注入那些 不产出 CSS 的编译时工具——也就是我们的变量和混入。这样,每个组件的 <style> 块都能随时使用 $GLColor@include flex-center

scss-resetbase 这些真正的全局样式呢?

它们由 main.scss 统一管理,而我们只需要在项目的 JavaScript 入口,也就是 main.ts 中,import '@/styles/main.scss' 一次。这样 Vite 就会把它们打包成一个独立的、被所有页面共享的 CSS 文件。一次加载,全局生效。这就是企业级的最佳实践。

1.6.7 实施最终配置

现在,我们将整合所有配置,实施我们讨论出的最佳注入策略。

1. 更新 vite.config.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
// vite.config.ts
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// 1. 引入所需插件
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

// 引入 unplugin-icons
import Icons from "unplugin-icons/vite";
import IconsResolver from "unplugin-icons/resolver";

export default defineConfig({
plugins: [
vue(),
// 2. 配置 unplugin-auto-import
AutoImport({
resolvers: [ElementPlusResolver()],
}),
// 3. 配置 unplugin-vue-components
Components({
resolvers: [
// 1. 配置 ElementPlusResolver,指定 importStyle 为 sass
ElementPlusResolver({ importStyle: "sass" }),
// 自动导入 Element Plus 图标
IconsResolver({
prefix: "i", // 默认为 i, 可不写
enabledCollections: ["ep"], // ep 是 element-plus 的图标库
}),
],
}),
// 4. 配置 unplugin-icons
Icons({
autoInstall: true,
}),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
// 2. SCSS 自动化注入配置
css: {
preprocessorOptions: {
scss: {
// 只注入变量和 mixins,避免重复导入 CSS 输出
additionalData: `
@use "@/styles/abstracts/variables" as *;
@use "@/styles/abstracts/mixins" as *;
@use "@/styles/abstracts/utilities" as *;
`,
},
},
},
});

2. 更新 main.ts

1
2
3
4
5
6
7
8
9
10
11
12
// src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import ElementPlus from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";

// 核心:导入唯一的全局样式文件
import "@/styles/main.scss";

const app = createApp(App);
app.use(ElementPlus, { locale: zhCn });
app.mount("#app");

1.6.8 清理与测试

  1. 确认 main.ts: 确保 import 'element-plus/dist/index.css' 已被彻底删除,因为我们的样式体系已经完美接管。

  2. 功能测试: 重启 Vite 开发服务器。

    • 全局样式: 页面基础排版(字体、背景色等)应已正确应用。这证明 main.scss 已被成功加载一次。
    • Element Plus 定制: <el-button type="primary"> 按钮应显示为我们定义的 $GLColor
    • 组件内工具: 在任意 Vue 组件的 <style lang="scss"> 块中,你都可以直接使用 $GLColor 变量或 @include flex-center; 混入来编写该组件的 独有样式,而无需任何手动导入。

通过这套架构,我们实现了企业级项目的终极目标:一套清晰、高效、可维护的样式体系,它完美平衡了全局一致性与组件独立性。


1.7 核心服务集成

一个应用不仅需要美观的 UI,还需要强大的 “内脏” 来处理数据请求、页面导航和状态管理。本节将为我们的项目集成这些核心服务,搭建起应用的 “神经与循环系统”。

当前任务: 1.7 - 核心服务集成
任务目标: 为项目安装并配置 Axios (网络请求)、Vue Router (路由)、Pinia (客户端状态) 和 TanStack Query (服务端状态)。

1.7.1 网络请求层 (Axios) 封装

在任何项目中,直接在组件中调用 axios.get(...) 都是一种不良实践。我们需要一个统一的、可配置的封装层来处理通用逻辑。

  • 设计思路: 我们将创建一个 axios 实例,并配置通用的 baseURLtimeout。更重要的是,我们将利用 拦截器 (Interceptors) 来实现:
    1. 请求拦截器: 未来用于统一注入用户 token。
    2. 响应拦截器: 用于统一处理返回数据的结构(例如,直接返回 res.data)和集中的错误处理(例如,401 未授权拦截)。
开发者日记
开发中

架构师,我们现在要开始做 API 请求了。我之前的习惯是在需要请求的组件里直接 import axios from 'axios' 然后就用了,这样做有什么问题吗?

架构师

这是一个很常见的起点,但在大型项目中会很快演变成一场灾难。想象一下,如果后端 API 的 baseURL 换了,或者所有请求都需要加一个统一的 header,你是不是得去几十个文件里一个个修改?

…好像是的,那维护成本太高了。

架构师

完全正确。所以,企业级项目的最佳实践,是创建一个全局的、封装好的 axios 实例。我们将所有的基础配置,比如 baseURL、超时时间,都放在这个文件里。更强大的是,我们可以用 “拦截器” 来打造请求的 “自动化流水线”。

“自动化流水线”?听起来很酷。

架构师

是的。比如,请求拦截器可以在每个请求发出去之前,自动检查并带上用户的 token。响应拦截器可以在收到数据后,自动帮架构师剥离掉外层的 data 包装,或者在遇到 401 这种通用错误时,直接弹出提示并跳转到登录页。这样架构师的业务组件就只需要关心业务本身了。

明白了!单一职责原则,让专业的工具做专业的事。那我们这个封装文件应该怎么写?

架构师

很好,你已经领悟到精髓了。我来给你提供一个非常标准的、具备良好扩展性的基础封装结构。

1. 依赖安装

1
pnpm add axios

2. 封装 HTTP 工具模块

src/utils/ 目录下创建 http.ts 文件。

请将以下代码填入 src/utils/http.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
// src/utils/http.ts
import axios from "axios";

const httpInstance = axios.create({
baseURL: "http://localhost:3001",
timeout: 5000,
});

// axios 请求拦截器
httpInstance.interceptors.request.use(
(config) => {
// 1. 从 pinia 获取 token 数据
// 2. 按照后端的要求拼接 token 数据
return config;
},
(e) => Promise.reject(e)
);

// axios 响应式拦截器
httpInstance.interceptors.response.use(
// 剥离了一层 data,后续直接拿到的就是响应 data
(res) => res.data,
(e) => {
// 1. 统一错误提示
// 2. 401 token 失效处理
return Promise.reject(e);
}
);

export default httpInstance;

代码解读:

  • axios.create: 创建一个独立的 axios 实例,避免污染全局 axios。
  • interceptors.request: 在请求发送前进行拦截。我们在这里预留了未来添加 token 的逻辑。
  • interceptors.response: 在响应到达 then/catch 之前进行拦截。我们在这里做了两件重要的事:
    1. 成功回调中 res => res.data,这可以让我们在后续调用时直接获取数据,无需再 response.data
    2. 失败回调中预留了统一错误处理逻辑,例如 token 失效后的跳转登录页。

1.7.2 路由系统 (Vue Router)

Vue Router 是 Vue 官方的路由管理器,是构建单页应用 (SPA) 的标准配置。

1. 依赖安装

1
pnpm add vue-router

2. 创建路由配置文件

src/ 目录下创建 router/index.ts 文件。

开发者日记
开发中

架构师,关于路由,我们这个电商项目页面还挺多的。比如有首页、登录页、分类页…我是不是应该给每个页面都创建一个顶级路由,比如 //login/category

架构师

这是个好问题,它涉及到路由设计的核心思想。你看,首页和分类页,它们是不是都有共同的页头和页脚?

是的,都有。登录页没有。

架构师

这就是关键。我们会创建一个 Layout 组件,作为所有 “有公共布局” 页面的父级容器。然后,首页、分类页都作为它的 “子路由” 存在。当用户在这些页面间切换时,实际上只是 Layout 组件内部的一部分在变化,而页头页脚保持不变。这不仅代码复用性好,用户体验也更流畅。登录页则是一个独立的顶级路由。

原来如此!这就是嵌套路由的实际应用场景。那 path 该怎么写?首页的 path 应该是 /home 吗?

架构师

一个小技巧是,当 Layoutpath/ 时,你可以给首页子路由一个空的 path: ''。这样,访问根路径 / 时,就会自动渲染 Layout 并把首页作为它的默认内容。

明白了,这样设计清晰多了。

请将以下代码填入 src/router/index.ts 文件中,并根据需要创建对应的 .vue 文件 (可以先只写一个 <template><div>...</div></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
30
// src/router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import Layout from "@/views/Layout/index.vue";
import Login from "@/views/Login/index.vue";

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
component: Layout,
children: [
{
path: "", // 默认二级路由
component: () => import("@/views/Home/index.vue"),
},
{
path: "category/:id", // 使用 path parameter 接收 id
component: () => import("@/views/Category/index.vue"),
},
],
},
{
path: "/login",
component: Login,
},
],
});

export default router;

代码解读:

  • createWebHistory: 使用 H5 History 模式,URL 路径更美观。
  • routes: 路由规则数组。我们定义了一个 嵌套路由
    • / 路径会渲染 Layout 组件,它作为所有一级页面的通用布局(包含导航、页头、页脚)。
    • children 数组定义了二级路由。当访问 / 时,由于 path: '',会默认在 Layout 组件的 <RouterView /> 中渲染 Home 组件。

1.7.3 状态管理引擎安装与最终注册

最后,我们安装 PiniaTanStack Query,并在 main.ts 中完成所有核心服务的最终注册。

1. 依赖安装

1
2
pnpm add pinia @tanstack/vue-query
pnpm add pinia-plugin-persistedstate

@tanstack/vue-query 的开发者工具 vue-query-devtools 在生产环境中不会被打包,但为了方便调试,推荐在开发依赖中安装它:pnpm add -D @tanstack/vue-query-devtools

2. 在 main.ts 中完成所有服务注册

这是我们 main.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
// src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router"; // 导入路由
import { createPinia } from "pinia";
import { VueQueryPlugin } from "@tanstack/vue-query";
import ElementPlus from "element-plus";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate"; // 1. 导入插件
import zhCn from "element-plus/es/locale/lang/zh-cn";

// 核心:导入唯一的全局样式文件
import "@/styles/main.scss";

const app = createApp(App);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); // 2. 注册插件
app.use(pinia);
app.use(router); // 注册路由
app.use(VueQueryPlugin); // 注册 TanStack Query

app.use(ElementPlus, {
locale: zhCn,
});

app.mount("#app");

1.8 API 自动导入优化

在【任务 1.3】中,我们已经为项目配置了路径别名,解决了深层相对路径的问题。本节,我们将解决另一个影响开发效率的痛点:在每个 Vue 组件中重复编写 import { ref, computed, ... } from 'vue'

当前任务: 1.8 - API 自动导入优化
任务目标: 配置 unplugin-auto-import 插件,实现对 Vue、Vue Router 等核心库 API 的自动导入,让开发者更专注于业务逻辑。

1.8.1 设计思路:解放 import 语句

unplugin-auto-import 插件的原理是在编译时扫描代码,如果发现你使用了像 ref, computed, onMounted 等未被导入的变量,它会自动检查这些变量是否属于预设库(如 ‘vue’, ‘vue-router’)的导出成员。如果是,它会自动为架构师添加导入语句。为了让 TypeScript 理解这些 “凭空出现” 的全局变量,该插件还会生成一个类型声明文件(.d.ts)。

1.8.2 Vite 配置文件 (vite.config.ts)

我们将扩展 unplugin-auto-import 的配置,明确告知它需要为哪些库开启自动导入。

开发者日记
开发中

架构师,路径别名确实方便多了。但现在我还是很烦,每个组件都要写一遍 import { ref, computed } from 'vue',有没有办法让它也自动化?

架构师

问得好,这正是我们要解决的下一个问题。专业的工作流,就是要消灭一切不必要的重复性劳动。unplugin-auto-import 这个插件,就是为此而生的。

之前我们安装 Element Plus 的时候已经装过它了。

架构师

是的,当时我们只用了它的基础功能。现在,我们要深入配置它,让它不仅处理 Element Plus 的 API,更能处理 Vue 和 Vue Router 的所有核心 API。我们还需要配置类型声明的生成路径,确保 TypeScript 和 IDE 能够正确识别这些 “凭空出现” 的全局变量。

明白了,这样我们就彻底告别手动 import 了!

架构师

完全正确!一旦配置完成,你在任何组件中都可以直接使用 ref, computed, useRouter 等 API,就像它们是全局变量一样自然。

现在,让我们扩展 vite.config.tsunplugin-auto-import 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// vite.config.ts
// ... imports
import AutoImport from "unplugin-auto-import/vite";
// ...

export default defineConfig({
plugins: [
vue(),
// 扩展 unplugin-auto-import 配置
AutoImport({
// 1. 指定需要自动导入的库
imports: ["vue", "vue-router"],
// 2. 指定生成 d.ts 文件的位置
dts: "src/auto-imports.d.ts",
// 3. 配置 ElementPlusResolver
resolvers: [ElementPlusResolver()],
}),
Components({
// ... existing config
}),
// ... other plugins
],
// ... resolve and css config
});

配置解读:

  • imports: ['vue', 'vue-router']: 我们明确告诉插件,自动扫描并导入 vuevue-router 这两个库导出的所有 API。
  • dts: 'src/auto-imports.d.ts': 这是至关重要的一步。插件会创建一个 auto-imports.d.ts 文件,其中包含了所有自动导入 API 的 TypeScript 类型声明。

1.8.3 TypeScript 类型支持

我们需要确保 TypeScript 配置文件能够识别这个自动生成的类型声明文件。

tsconfig.app.json 文件中,确保 include 数组包含了 auto-imports.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
// tsconfig.app.json
{
// ... existing config
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"auto-imports.d.ts",
"components.d.ts"
]
}

1.8.4 功能测试

现在,让我们创建一个测试组件来验证自动导入是否正常工作:

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/components/AutoImportTest.vue -->
<script setup lang="ts">
// 注意:我们没有手动导入任何东西!
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
const router = useRouter();

const increment = () => {
count.value++;
};

const goHome = () => {
router.push("/");
};

onMounted(() => {
console.log("组件已挂载,自动导入测试成功!");
});
</script>

<template>
<div>
<p>Count: {{ count }}</p>
<p>Double Count: {{ doubleCount }}</p>
<el-button @click="increment">增加</el-button>
<el-button @click="goHome">回到首页</el-button>
</div>
</template>

重启 Vite 开发服务器后,如果上述代码能够正常运行且 VS Code 提供了完整的类型提示,说明自动导入配置成功!

配置成功标志: 当你输入 refcomputed 等 API 时,VS Code 会自动提供智能提示,且无需手动导入即可正常使用。同时,你会发现项目根目录生成了 auto-imports.d.ts 文件。

最终完整的 vite.config.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
// vite.config.ts
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// 1. 引入所需插件
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

// 引入 unplugin-icons
import Icons from "unplugin-icons/vite";
import IconsResolver from "unplugin-icons/resolver";

export default defineConfig({
plugins: [
vue(),
// 2. 配置 unplugin-auto-import
AutoImport({
// 1. 指定需要自动导入的库
imports: ["vue", "vue-router"],
// 2. 指定生成 d.ts 文件的位置
dts: "src/auto-imports.d.ts",
// 3. 配置 ElementPlusResolver
resolvers: [ElementPlusResolver()],
}),
// 3. 配置 unplugin-vue-components
Components({
resolvers: [
// 自动导入 Element Plus 组件
ElementPlusResolver({ importStyle: "sass" }),
// 自动导入 Element Plus 图标
// 我们约定图标组件的使用方式是 <i-ep-iconName />
IconsResolver({
prefix: "i", // 默认为 i, 可不写
enabledCollections: ["ep"], // ep 是 element-plus 的图标库
}),
],
}),
// 4. 配置 unplugin-icons
Icons({
autoInstall: true,
}),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},

server: {
proxy: {
// 代理 API 请求到 Mock 服务器
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
// 代理图片资源到 Mock 服务器
"/images": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
// 2. SCSS 自动化注入配置
css: {
preprocessorOptions: {
scss: {
// 只注入变量和 mixins,避免重复导入 CSS 输出
additionalData: `
@use "@/styles/abstracts/variables" as *;
@use "@/styles/abstracts/mixins" as *;
@use "@/styles/abstracts/utilities" as *;
`,
},
},
},
});

1.9 自动化提交流程

IDE 级别的配置能提升个人效率,但无法保证团队协作的一致性。我们需要在代码流入版本库的最后一道关卡——git commit——设立 “自动化哨兵”,强制所有提交都遵循统一的质量和规范标准,确保代码仓库的纯净与专业。

当前任务: 1.9 - 自动化提交流程
任务目标: 初始化 Git 仓库并推送到 GitHub,然后集成 Husky, lint-staged, commitlint,构建一个在提交代码前自动执行 “代码格式化”、“质量检查” 和 “提交信息规范校验” 的自动化工作流。

1.9.1 设计思路:三道防线

我们将建立三道质量防线:

  1. 第一道防线 - 预提交检查 (pre-commit Hook): 在 git commit 执行前,自动对暂存区的文件运行 ESLintPrettier,确保所有即将提交的代码都符合质量和风格标准。
  2. 第二道防线 - 提交信息校验 (commit-msg Hook): 校验提交信息是否遵循约定式提交规范,确保提交历史的专业性和可读性。
  3. 第三道防线 - 推送前检查: 在推送到远程仓库前,可以添加额外的检查,如运行完整的测试套件。

1.9.2 Git 仓库初始化与远程连接

首先,我们需要为项目创建 Git 仓库并连接到远程仓库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 初始化 Git 仓库
git init

# 2. 添加所有文件到暂存区
git add .

# 3. 创建初始提交
git commit -m "feat: initial project setup with Vue 3 + TypeScript"

# 4. 连接到远程 GitHub 仓库(请替换为你的仓库地址)
git remote add origin https://github.com/your-username/vue3-webshop.git

# 5. 推送到远程仓库
git push -u origin main

1.9.3 依赖安装

现在安装所有自动化工具:

1
pnpm add -D husky lint-staged @commitlint/cli @commitlint/config-conventional

依赖解读:

包名核心职责
huskyGit Hooks 工具,让我们能轻松地在 pre-commitcommit-msg 等 Git 事件上挂载自定义脚本。
lint-staged一个只对 Git 暂存区 内的文件执行任务的工具,极大地提升了检查效率。
@commitlint/clicommitlint 的命令行工具,用于校验提交信息。
@commitlint/config-conventionalcommitlint 的一个预设配置包,它规定了我们必须遵循 “约定式提交” 规范。

1.9.4 配置 Huskylint-staged (代码检查)

  1. 初始化 Husky:

    1
    pnpm exec husky init
  2. 配置 lint-staged:
    package.json 中添加 lint-staged 配置:

    1
    2
    3
    4
    5
    6
    7
    8
    // package.json
    {
    // ... existing config
    "lint-staged": {
    "*.{js,jsx,ts,tsx,vue}": ["eslint --fix --cache", "prettier --write"],
    "*.{json,md,scss,css}": ["prettier --write"]
    }
    }
  3. 创建预提交钩子:
    修改 .husky/pre-commit 文件:

    1
    2
    # .husky/pre-commit
    npx lint-staged

工作原理解读:

  • lint-staged 只会对 Git 暂存区(git add 后的文件)执行检查,大大提升了性能。
  • 对于 JS/TS/Vue 文件,先运行 eslint --fix 自动修复问题,再用 prettier 统一格式。
  • 对于配置文件和文档,只运行 prettier 格式化。

1.9.5 配置 commitlint (提交信息规范)

  1. 约定式提交规范说明:
    我们要求所有提交信息都遵循 type(scope): subject 的格式。这不仅使提交历史清晰可读,更是自动化生成 CHANGELOG 和版本管理的基础。

    类型 (type)含义
    feat新增功能
    fix修复 Bug
    docs文档变更
    style代码风格调整(不影响逻辑)
    refactor代码重构
    perf性能优化
    test新增或修改测试
    chore构建流程、辅助工具的变更
  2. 创建 commitlint.config.js:
    在项目根目录创建配置文件:

    1
    2
    3
    4
    // commitlint.config.js
    export default {
    extends: ["@commitlint/config-conventional"],
    };
  3. 创建提交信息钩子:
    创建 .husky/commit-msg 文件:

    1
    2
    # .husky/commit-msg
    npx --no-install commitlint --edit $1

1.9.6 添加便捷脚本

为了方便开发,在 package.json 中添加一些实用的脚本:

1
2
3
4
5
6
7
8
9
10
11
// package.json
{
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"format": "prettier --write ."
}
}

1.9.7 完整测试流程

现在让我们测试整个自动化工作流是否正常:

  1. 故意制造一些格式问题: 在任意 .vue 文件中添加一些不规范的代码(如缺少分号、不一致的引号等)。

  2. 测试预提交检查:

    1
    2
    git add .
    git commit -m "test commit"

    你应该看到 lint-staged 自动运行,修复代码格式问题。

  3. 测试提交信息校验:

    执行一次 不符合规范 的提交:git commit -m "test"

    • 预期结果: 终端会提示 subject may not be empty, type may not be empty 等错误,提交被 中止

    执行一次 符合规范 的提交:git commit -m "feat: setup automated commit workflow"

    • 预期结果:
      lint-staged 首先运行,你会看到 eslintprettier 的输出,格式问题被自动修复。
      commitlint 校验通过。提交成功!

成功标志: 当你能够成功提交一个符合规范的 commit,并且在过程中看到代码被自动格式化,说明整个自动化提交流程已经完美搭建完成!

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

本模块任务清单

产品经理 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: 简明扼要地描述了我们完成的具体工作:构建了主布局和动态化的首页。

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


模块三:登录与用户认证

本章概述: 欢迎来到模块三。在本章中,我们将构建一个 真正企业级 的用户认证系统。我们将从零开始,在 Mock 后端实现 双 Token 认证机制。在前端,我们将深度封装 Axios,通过 请求和响应拦截器 实现 Access Token 的无感刷新。最后,我们将结合 ElForm 的强大校验功能和 useMutation,完成一个健壮、安全、用户体验极佳的登录流程,并为应用添加路由守卫,实现完整的认证闭环。

image-20250911155106817

本模块任务清单

任务模块任务名称核心目标与学习要点
后端改造升级 Mock 后端以支持双 Token改造 /login 接口返回双 Token,并新增 /token/refresh 接口用于无感续期。
状态管理升级 Pinia (userStore)改造 userStore,使其能分别存储和管理用户信息、accessTokenrefreshToken
【核心】网络层深度封装 Axios 拦截器实现请求拦截器自动注入 Token,以及响应拦截器处理 401 错误、无感刷新 Token 和重试请求。
UI 与校验构建登录 UI 与表单校验使用 ElForm 构建登录表单,配置声明式校验规则,并为未来的 QQ/手机号登录预留入口。
【核心】数据变更整合 useMutation 完成登录使用 useMutation 处理登录提交,并在 onSuccess 回调中,调用 userStoreaction 将双 Token 持久化。
应用安全实现路由守卫与退出登录使用 vue-router 的导航守卫保护需登录页面,并实现一个能清除所有状态的退出登录功能。

3.1 任务规划与设计思路

一个优秀的前端认证系统,其核心在于 对 Token 的自动化管理,对用户应该是几乎无感的。

认证架构设计
架构设计阶段

在企业级项目中,为什么认证方案通常需要两个 Token?一个 accessToken 不够吗?

架构师

这是一个经典的安全与体验的权衡问题。Access Token 用来访问受保护资源,它的有效期通常很短(比如 30 分钟),即使被截获,风险也有限。但如果让用户每半小时就重新登录一次,体验会非常糟糕。

所以 Refresh Token 就派上用场了,用来换新的 Access Token

架构师

完全正确。Refresh Token 的有效期很长(比如 7 天),它 唯一的作用 就是去换取一个新的、短期的 Access Token。当 Access Token 过期时,我们的前端应用应该能自动、在后台用 Refresh Token 去换一个新的 Access Token,然后无缝地继续之前的操作,用户完全感觉不到这个过程。

这个“自动、在后台”的过程,听起来很复杂。

架构师

这正是我们要深度封装 Axios 拦截器的原因。我们将打造一个“自动化流水线”:请求发出时,自动带上 Access Token;当收到 401 (未授权) 响应时,自动暂停当前请求,去刷新 Token,成功后再自动重试刚才失败的请求。这套机制,就是企业级认证的核心。

我们的搭建路线图将围绕这套企业级认证体系展开:

  1. 升级 Mock 后端: 实现双 Token 认证机制。
  2. 升级 Pinia (userStore): 使其能够管理双 Token。
  3. 【核心】深度封装 Axios 拦截器: 实现 Token 的自动注入与无感刷新。
  4. 构建登录页面 UI 与表单校验: 搭建视图并实现 ElForm 校验。
  5. 整合 useMutation 完成登录: 处理表单提交与回调。

3.2 编码实现:升级 Mock 后端以支持双 Token

要实现前端的无感刷新,后端必须提供相应的支持。我们需要改造 Mock Server,使其从一个简单的数据提供者,升级为一个具备签发和刷新 Token 能力的、更真实的模拟认证服务器。

涉及文件: mock/generate-data.cjs, mock/server.cjs
任务目标:

  1. 让用户数据包含 refreshToken
  2. 改造 /login 接口,使其在登录成功后返回带有过期时间的 accessToken 和一个长期有效的 refreshToken
  3. 新增 /token/refresh 接口,用于根据 refreshToken 换取新的 accessToken
  4. 新增一个中间件,用于模拟 accessToken 的过期校验。

1. 更新数据生成器

首先,我们需要在生成模拟用户数据时,为每个用户添加一个 refreshToken 字段。

请打开 mock/generate-data.cjs 并更新 users 的生成逻辑:

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

module.exports = () => {
const staticDataPath = path.join(__dirname, 'mock-data.json');
const staticData = JSON.parse(fs.readFileSync(staticDataPath, 'utf-8'));

const data = {
...staticData,
users: [],
};

// 根据 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(),
refreshToken: faker.string.uuid(), // 【新增】刷新令牌
avatar: faker.image.avatar(),
nickname: faker.person.firstName(),
mobile: faker.phone.number(),
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;
};

2. 改造 Mock Server

接下来是核心改造。我们将用 Express 中间件的方式,为 Mock Server 添加自定义的认证逻辑。

请打开 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
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
// mock/server.cjs
const jsonServer = require("json-server");
const { faker } = require("@faker-js/faker");
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);

// 【核心】添加一个中间件,模拟 Token 过期
server.use((req, res, next) => {
// 跳过登录和刷新 token 的接口,它们不需要校验
if (req.path === "/login" || req.path === "/token/refresh") {
return next();
}

const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.substring(7);
const parts = token.split("_");
if (parts.length === 2) {
const expirationTime = parseInt(parts[1], 10);
const currentTime = Date.now();

// 如果当前时间大于过期时间,则判定为 token 过期
if (currentTime > expirationTime) {
return res.status(401).json({
message: "Access Token 已过期",
code: "401",
});
}
}
}

next();
});

// 自定义 /login 路由
server.post("/login", (req, res) => {
const { account, password } = req.body;
const db = router.db;
const user = db.get("users").find({ account, password }).value();

if (user) {
const { password, ...userInfo } = user;
// 【核心】生成一个短期的、带过期时间戳的 accessToken
// 为了方便测试,设置为 1 分钟过期
const accessToken = `${faker.string.uuid()}_${Date.now() + 1 * 60 * 1000}`;

res.status(200).json({
code: "200",
msg: "操作成功",
result: {
...userInfo,
accessToken, // 返回新的 accessToken
},
});
} else {
res.status(400).json({
message: "用户名或密码错误",
code: "400",
});
}
});

// 【新增】Token 刷新接口
server.post("/token/refresh", (req, res) => {
const { refreshToken } = req.body;
const db = router.db;
const user = db.get("users").find({ refreshToken }).value();

if (user && refreshToken) {
// 刷新成功,生成一个新的、带过期时间戳的 accessToken
const newAccessToken = `${faker.string.uuid()}_${Date.now() + 30 * 60 * 1000}`; // 30 分钟后过期
res.status(200).json({
code: "200",
msg: "刷新成功",
result: {
accessToken: newAccessToken,
},
});
} else {
res.status(401).json({
message: "登录状态无效,请重新登录",
code: "401",
});
}
});

// 其他已有的自定义路由...
server.get("/categories", (req, res) => {
const db = router.db;
const categories = db.get("categories").value();
res.status(200).json({ code: "200", msg: "操作成功", result: categories });
});

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);

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

3. 代码解读与验证

  • Token 过期模拟: 我们添加了一个 Express 中间件。对于除登录和刷新外的所有请求,它会检查 Authorization 头。我们巧妙地将过期时间戳用 _ 连接在模拟 Token 的后面。中间件解析出这个时间戳,并与当前时间比较,如果超时,就返回 401 状态码。这完美地模拟了真实世界中的 Token 过期场景。
  • /login 接口: 登录成功后,会生成一个 1 分钟后过期accessToken。这便于我们快速测试无感刷新功能。
  • /token/refresh 接口: 接收 refreshToken,如果有效,就返回一个有效期为 30 分钟的 全新 accessToken

在继续前端改造之前,请务必重启 Mock Server (pnpm run mock)。我们的后端现在已经具备了完整的、企业级的双 Token 签发和刷新能力,这是构建前端无感刷新功能坚实的 后端基础


3.3 编码实现:升级 Pinia 以管理双 Token

Pinia 作为我们应用的“唯一事实来源”,必须能够准确地反映用户的完整认证状态。这不仅仅包括用户信息,更核心的是认证所需的 accessTokenrefreshToken

文件路径: src/stores/user.ts
核心目标: 重构 userStore,使其能统一存储用户信息和双 Token。我们将添加专门的 computed 属性来安全地获取 accessTokenrefreshToken,并利用持久化插件确保登录状态的稳定。

1. 设计思路

一个健壮的企业级 userStore 应该具备以下特点:

  • 状态统一管理: 将用户的基本信息与认证令牌统一存储在一个 userInfo 对象中。这样做权责清晰,当获取到新的登录数据或刷新 Token 后,可以作为一个整体进行更新。
  • 安全的访问器 (Getters): 不在组件中直接访问 store.userInfo.accessToken,而是通过 computed 属性(Pinia 中的 Getters)来获取。这层抽象让组件无需关心 userInfo 的内部结构,也便于在 Getter 中处理 nullundefined 等边界情况。
  • 原子化操作: 提供统一的 action (setUserInfo) 来一次性更新所有登录相关状态,避免状态不一致。同理,clearUserInfo 方法用于安全退出,确保所有认证信息被完整清除。

2. 完整代码实现

现在,我们将对 src/stores/user.ts 文件进行全面升级。

请打开 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
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
// src/stores/user.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { loginApi } from "@/apis/user";
import type { UserInfo, LoginForm } from "@/types/user";

export const useUserStore = defineStore(
"user",
() => {
// 1. state: 统一存储用户所有信息
const userInfo = ref<UserInfo | object>({});

// 2. action: 登录并设置用户信息
const getUserInfo = async (form: LoginForm) => {
const res = await loginApi(form);
userInfo.value = res.result;
};

// 3. action: 统一设置用户信息的方法,用于登录和刷新 Token
const setUserInfo = (newUserInfo: UserInfo) => {
userInfo.value = newUserInfo;
};

// 4. action: 清除用户信息,用于退出登录
const clearUserInfo = () => {
userInfo.value = {};
};

// 5. getter (computed): 派生状态,判断是否登录
const isLoggedIn = computed(() => {
const info = userInfo.value as UserInfo;
return !!info.accessToken;
});

// 6. getter (computed): 安全地获取 accessToken
const getAccessToken = computed(() => {
const info = userInfo.value as UserInfo;
return info.accessToken || null;
});

// 7. getter (computed): 安全地获取 refreshToken
const getRefreshToken = computed(() => {
const info = userInfo.value as UserInfo;
return info.refreshToken || null;
});

return {
userInfo,
getUserInfo,
setUserInfo,
clearUserInfo,
isLoggedIn,
getAccessToken,
getRefreshToken,
};
},
{
persist: true, // 开启持久化
}
);

3. 验证持久化效果

虽然我们还未集成登录页面,但可以手动进行验证。

  1. 在任意组件(例如 App.vue)的 <script setup> 中,临时添加以下测试代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import { useUserStore } from '@/stores/user'

    const userStore = useUserStore()
    userStore.setUserInfo({
    id: 'test-id',
    account: 'test-user',
    nickname: 'Tester',
    avatar: '',
    accessToken: 'dummy-access-token-123',
    refreshToken: 'dummy-refresh-token-456'
    })
  2. 运行项目,并打开浏览器的开发者工具。
  3. 在 “Application” (应用) -> “Local Storage” (本地存储空间) 中,你应该能看到一个名为 user 的条目,其内容是一个包含了我们刚刚设置的所有信息的 JSON 字符串。
  4. 现在,注释掉或删除 刚刚添加的测试代码并 刷新页面。你会发现 Pinia 状态依然存在,因为它已成功从 Local Storage 中恢复。

我们的 Pinia store 现已升级为一个能够妥善管理企业级认证状态的“保险箱”。它不仅结构清晰、类型安全,而且借助持久化插件,确保了用户登录状态的稳定可靠。


3.4 【核心】编码实现:深度封装 Axios 拦截器

本章目标: 对我们全局的 Axios 实例进行深度封装。我们将实现一个 请求拦截器 来自动为请求注入 accessToken,以及一个更复杂的 响应拦截器。这个响应拦截器将能自动捕获 401 (未授权) 错误,并在后台使用 refreshToken 无感地刷新 accessToken,然后 自动重试 之前失败的请求,为用户提供无缝的登录体验。

1. 设计思路:打造“自愈式”的请求流水线

在开始编码前,让我们先进行一次技术对话,明确我们的目标。

封装前的思考
准备封装网络层

架构师,现在后端接口和 Pinia Store 都好了。我是不是可以在需要 Token 的 API 请求函数里,手动从 Pinia 取 Token 然后加到 Header 里?

架构师

技术上可以,但这是企业级项目的大忌。你的每个 API 请求函数都要重复写一遍这个逻辑。更糟糕的是,如果 accessToken 过期了怎么办?在每个发起请求的组件里都写一遍 if (error.status === 401) 的判断吗?

那会是一场维护灾难!

架构师

完全正确。所以,我们要打造一个“中央处理中心”——也就是深度封装的 axios 实例。我们将用它的 拦截器 (Interceptors) 功能,打造一条自动化流水线。

怎么个自动化法?

架构师

请求拦截器 负责在每个请求发出前,自动检查并带上 accessToken响应拦截器 负责在收到响应后,自动检查是不是 401 错误。如果是,它会 暂停 当前失败的请求,在后台悄悄用 refreshToken 换一个新的 accessToken,成功后再用新 Token 自动重试 刚才失败的请求。

哇!这样一来,我的业务组件就只需要关心“发请求”和“拿数据”,完全不用知道 Token 的存在和过期问题了!

架构师

这就是我们要做的——把认证的复杂性,彻底封装在网络层。

2. 准备工作:创建 Token 刷新 API

在封装拦截器之前,我们需要先在 src/apis/user.ts 文件中创建一个专门用于刷新 Token 的 API 函数。

请打开 src/apis/user.ts 并添加以下函数:

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

// ... loginApi 函数保持不变 ...

/**
* 刷新 Access Token 的 API
* @param refreshToken - 刷新令牌
* @returns Promise <{ result: { accessToken: string } }>
*/
export const refreshTokenAPI = (refreshToken: string): Promise<{ result: { accessToken: string } }> => {
return httpInstance({
method: "POST",
url: "/token/refresh",
data: {
refreshToken,
},
});
};

3. 完整代码实现:封装 http.ts

现在,我们准备好对 src/utils/http.ts 进行终极改造了。

请打开 src/utils/http.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
// src/utils/http.ts
import axios from "axios";
import { useUserStore } from "@/stores/user";
import router from "@/router"; // 引入 router 实例
import { refreshTokenAPI } from "@/apis/user"; // 引入刷新 token 的 API
import type { UserInfo } from "@/types/user";

const httpInstance = axios.create({
baseURL: "/api",
timeout: 5000,
});

// axios请求拦截器
httpInstance.interceptors.request.use(
(config) => {
const userStore = useUserStore();
const token = userStore.getAccessToken;
if (token) {
// 按照后端的要求,将 token 放入请求头
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(e) => Promise.reject(e)
);

// 防止重复刷新token的标识
let isRefreshing = false;

// axios响应式拦截器
httpInstance.interceptors.response.use(
// 剥离了一层 data,后续直接拿到的就是响应 data
(res) => res.data,
async (error) => {
// 检查是否是 401 未授权错误,且不是刷新token的请求,且没有重试过
if (
error.response?.status === 401 &&
error.config.url !== "/token/refresh" &&
!error.config._retry &&
!isRefreshing
) {
error.config._retry = true; // 标记已重试
isRefreshing = true; // 标记正在刷新

const userStore = useUserStore();
const refreshToken = userStore.getRefreshToken;

// 如果没有 refreshToken,说明用户从未登录或登录已彻底失效
if (!refreshToken) {
isRefreshing = false;
userStore.clearUserInfo();
router.push("/login");
return Promise.reject(error);
}

try {
// 尝试使用 refreshToken 获取新的 accessToken
const refreshRes = await refreshTokenAPI(refreshToken);
const newAccessToken = refreshRes.result.accessToken;

// 更新 Pinia 中的 accessToken
const currentUserInfo = userStore.userInfo as UserInfo;
userStore.setUserInfo({
...currentUserInfo,
accessToken: newAccessToken,
});

// 重试刚才失败的请求
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
isRefreshing = false; // 重置刷新标识
return await httpInstance(error.config);
} catch (refreshError) {
// 如果刷新 token 也失败了,则清除所有用户信息并跳转到登录页
isRefreshing = false; // 重置刷新标识
userStore.clearUserInfo();
router.push("/login");
return Promise.reject(refreshError);
}
}

return Promise.reject(error);
}
);

export default httpInstance;

4. 深度代码解读

这段代码是整个认证系统的“心脏”,让我们逐一解析它的工作机制:

  • 请求拦截器 (interceptors.request):

    • 职责: 自动化注入 Token。
    • 流程: 在每个请求被发送到服务器之前,这个拦截器都会启动。它会从 userStore 中获取 accessToken。如果存在,就将其以 Bearer 格式添加到请求的 Authorization 头中。
  • 响应拦截器 (interceptors.response):

    • 职责: 剥离数据和处理核心错误,尤其是 401
    • 成功分支 (res => res.data): 对于成功的请求 (2xx 状态码),我们直接返回 res.data。这是一种常见的优化,让业务代码在 .then() 中直接拿到后端 result 数据,无需再写 .data.result
    • 失败分支 (async (error) => { ... }): 这是我们的核心逻辑。
      1. 精确打击: if (error.response?.status === 401) 确保我们只处理 401 (未授权) 错误。
      2. try...catch 保护: 刷新 Token 的过程本身也可能失败,所以我们用 try...catch 块来包裹这个“修复”操作。
      3. try 块 (尝试修复):
        • 调用我们刚创建的 refreshTokenAPI
        • 成功后,用返回的 newAccessToken 更新 Pinia 状态。
        • 最关键一步: error.config 保存了上一次失败请求的所有配置。我们修改它的 headers,换上新 Token,然后 return await httpInstance(error.config)。这就相当于 用新令牌重新发送了一次刚才失败的请求。这个 return 会将 重试成功后的结果 返回给最初调用 API 的业务组件,实现无缝衔接。
      4. catch 块 (修复失败): 如果 refreshTokenAPI 本身就返回了 401 或其他错误,说明 refreshToken 也失效了。此时,认证状态彻底无效,我们清空 Pinia 并强制跳转到登录页。

关键陷阱: 在 Piniastore 文件之外使用 useUserStore(),必须在实际需要它的函数 内部 调用,而不能在文件的顶层作用域调用。这是因为 Pinia 实例的挂载是在 main.ts 中,顶层作用域的代码执行时 Pinia 可能尚未准备好。

我们已经成功地为我们的应用构建了一个高度智能、具备“自愈”能力的“神经网络”。现在,任何组件发起的 API 请求都自带了‘无感认证’的超能力。


3.5 编码实现:构建登录 UI 与表单校验

后端、状态管理和网络层这三大“基础设施”已经就绪。现在,我们回到用户能直接感知的层面——构建一个美观且功能强大的登录页面。

本节,我们将采用分步走的策略,清晰地分离 视图逻辑 的构建过程。

第一步:搭建 UI 骨架 (视图层)

本节目标: 专注于 视觉实现。我们将准备好所有静态资源(图片、样式),并编写 Login.vue<template> 部分,搭建一个包含动画、导航和第三方登录入口的完整静态视图。

1. 准备静态资源
这个页面的样式和动画比较复杂。为了让我们可以专注于逻辑,我们将其作为一个“资源包”直接引入。

  • 图片资源: 请在 src/assets/ 目录下创建一个 login 文件夹,并放入 img1.png, img2.png, QQ.png, WeChat.png 四张图片。
  • 样式文件: 请在 src/styles/ 目录下创建 login.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
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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
// src/styles/login.scss
@use "sass:color";
@use "abstracts/variables" as *;

:root {
font-size: 15px;
}

.login-page {
margin: 0;
min-height: 100vh;
background: linear-gradient(
135deg,
color.adjust($GLColor, $lightness: 45%) 0%,
color.adjust($GLColor, $lightness: 50%) 25%,
color.adjust($sucColor, $lightness: 40%) 50%,
color.adjust($warnColor, $lightness: 35%) 75%,
color.adjust($GLColor, $lightness: 48%) 100%
);
position: relative;
overflow: hidden;
}

.login-page::after {
content: "";
display: block;
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}

.login-content {
width: 90vw;
height: 90vh;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
border-radius: 30px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.18);
display: flex;
max-width: 1200px;
max-height: 800px;

// 左侧装饰区域
.left-section {
flex: 1;
position: relative;

// 装饰图形动画
.sphere {
position: absolute;
left: 30%;
width: 90%;
z-index: 1;
animation: sphereAnimation 2s;
animation-fill-mode: forwards;
animation-timing-function: ease;
}

.people {
position: absolute;
left: -50%;
top: 20%;
width: 70%;
z-index: 2;
}

// 动画类
.p-animation {
animation: peopleAnimation 2s;
animation-fill-mode: forwards;
animation-timing-function: ease;
}

.p-other-animation {
animation-name: pOtherAnimation;
animation-direction: alternate;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: 3s;
}

.s-animation {
animation: sphereAnimation 2s;
animation-fill-mode: forwards;
animation-timing-function: ease;
}

.s-other-animation {
animation-name: sOtherAnimation;
animation-direction: alternate;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: 3s;
}
}

// 右侧表单区域
.right-section {
flex: 1;
position: relative;
z-index: 12;

// 顶部导航
.top-navigation {
width: 80%;
margin-left: 38px;
color: $GLColor;
font-size: 20px;
font-weight: 600;
position: absolute;
left: 50%;
top: 5%;
transform: translate(-50%, 0);

.nav-item {
float: left;
width: 150px;
height: 40px;
line-height: 40px;
text-align: center;
margin-right: 10px;
transition: $transition-duration;
cursor: pointer;

&:hover {
border: 0;
background-color: #fff;
border-radius: 50px;
box-shadow: -20px 10px 32px 1px rgba(182, 183, 185, 0.37);
}
}
}

// 表单容器
.form-wrapper {
width: 60%;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
text-align: left;

.form-title {
font-family: "Century Gothic", Times, serif;
margin: 30px 0;
color: $GLColor;
font-size: 28px;
font-weight: 600;
}

// Element Plus 表单样式覆盖
.el-form {
.el-form-item {
margin-bottom: 30px;

.el-input {
.el-input__wrapper {
height: 70px;
border-radius: $borderRadius * 2.5;
border: 0;
background-color: color.adjust($borderColor, $lightness: 5%);
box-shadow: none;
padding: 0 20px;

.el-input__inner {
color: $textColor;
font-family: "Century Gothic", Times, serif;
font-size: 20px;

&::placeholder {
color: $textColor-secondary;
}
}
}

&.is-focus .el-input__wrapper {
box-shadow: 0 0 0 1px $GLColor inset;
}
}

.el-checkbox {
.el-checkbox__label {
color: $textColor-secondary;
font-family: "Century Gothic", Times, serif;
}
}
}
}

// 忘记密码链接
.forgot-password {
display: block;
margin-top: -15px;
margin-bottom: 20px;
color: $textColor-secondary;
cursor: pointer;
text-decoration: none;
font-family: "Century Gothic", Times, serif;
transition: color $transition-duration;

&:hover {
color: $GLColor;
}
}

// 登录按钮
.login-button {
width: 100%;
height: 50px;
background-color: $GLColor;
border-radius: $borderRadius * 2.5;
font-size: 15px;
color: #fff;
border: 0;
font-weight: 600;
margin: 30px 0;
cursor: pointer;
box-shadow: -20px 28px 42px 0 rgba(1, 85, 178, 0.37);
font-family: "Century Gothic", Times, serif;
transition: all 0.3s ease;

&:hover {
background-color: color.adjust($GLColor, $lightness: -8%);
transform: translateY(-2px);
}

&:active {
transform: translateY(0);
}
}

// 其他登录方式
.other-login {
.divider {
width: 100%;
margin: 20px 0;
text-align: center;
display: flex;
align-items: center;
justify-content: space-between;

.line {
display: inline-block;
max-width: 35%;
width: 35%;
flex: 1;
height: 1px;
background-color: $borderColor;
}

.divider-text {
vertical-align: middle;
margin: 0px 20px;
display: inline-block;
width: 150px;
color: $textColor-secondary;
white-space: normal;
font-family: "Century Gothic", Times, serif;
}
}

.other-login-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;

.other-login-item {
width: 70px;
padding: 10px;
text-align: center;
border-radius: $borderRadius * 2.5;
cursor: pointer;
font-weight: 600;
color: $GLColor;
margin: 0 10px;
transition: 0.4s;

img {
width: 40px;
height: 40px;
vertical-align: middle;
}

span {
vertical-align: middle;
}

&:hover {
width: 80px;
height: 50%;
background-color: #fff;
border: 0;
box-shadow: -20px 10px 32px 1px rgba(182, 183, 185, 0.37);
}
}
}
}
}
}
}

// 背景动画已移除,改为静态渐变背景

// 元素动画
@keyframes sphereAnimation {
0% {
width: 10%;
}
100% {
width: 90%;
transform: translate(-30%, 5%);
}
}

@keyframes peopleAnimation {
0% {
width: 40%;
}
100% {
width: 70%;
transform: translate(90%, -10%);
}
}

@keyframes pOtherAnimation {
0% {
transform: translate(90%, -10%);
}
100% {
transform: translate(90%, -15%);
}
}

@keyframes sOtherAnimation {
0% {
transform: translate(-30%, 5%);
}
100% {
transform: translate(-30%, 10%);
}
}

// 响应式设计
@media (max-width: 768px) {
.login-content {
width: 95vw;
height: 95vh;
flex-direction: column;

.left-section {
flex: 0 0 40%;
}

.right-section {
flex: 1;

.top-navigation {
display: none;
}

.form-wrapper {
width: 80%;

.nav-item {
width: 120px;
font-size: 16px;
}
}
}
}
}

2. 编写模板与样式 (Login/index.vue)
现在,我们来编写 src/views/Login/index.vue 的模板和样式。

请打开 src/views/Login/index.vue 并用以下代码替换其 <template><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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<template>
<div class="login-page">
<div class="login-content">
<!-- 左侧动画区域 -->
<div class="left-section">
<img ref="peopleRef" src="@/assets/login/img2.png" class="people p-animation" alt="people" />
<img ref="sphereRef" src="@/assets/login/img1.png" class="sphere s-animation" alt="sphere" />
</div>

<!-- 右侧表单区域 -->
<div class="right-section">
<!-- 顶部导航 -->
<div class="top-navigation">
<div class="nav-item" @click="handleNavClick('首页')">
<span class="nav-text">首页</span>
</div>
<div class="nav-item" @click="handleNavClick('注册')">
<span class="nav-text">注册</span>
</div>
</div>

<!-- 表单区域 -->
<div class="form-wrapper">
<h1 class="form-title">欢迎登录我们的平台</h1>

<el-form ref="formRef" :model="formModel" :rules="rules" @submit.prevent="handleLogin">
<el-form-item prop="account">
<el-input v-model="formModel.account" placeholder="请输入邮箱或手机号" size="large" clearable />
</el-form-item>

<el-form-item prop="password">
<el-input v-model="formModel.password" type="password" placeholder="请输入密码" size="large" show-password clearable />
</el-form-item>

<el-form-item prop="agree">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<el-checkbox v-model="formModel.agree">
我同意用户协议和隐私政策
</el-checkbox>
<a href="#" class="forgot-password" @click.prevent="handleForgotPassword">
忘记密码?
</a>
</div>
</el-form-item>

<button type="submit" class="login-button" @click="handleLogin" :disabled="isPending">
{{ isPending ? '登录中...' : '登录' }}
</button>
</el-form>

<!-- 第三方登录 -->
<div class="other-login">
<div class="divider">
<span class="line"></span>
<span class="divider-text">或使用以下方式登录</span>
<span class="line"></span>
</div>
<div class="other-login-wrapper">
<div class="other-login-item" @click="handleThirdPartyLogin('QQ')">
<img src="@/assets/login/QQ.png" alt="QQ" />
</div>
<div class="other-login-item" @click="handleThirdPartyLogin('微信')">
<img src="@/assets/login/WeChat.png" alt="WeChat" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<style lang="scss" scoped>
@use '@/styles/login.scss';
</style>

此时,Login.vue<script setup> 部分还是空的。但如果你运行项目并访问 /login 页面,应该已经能看到一个完整的静态登录界面了。

第二步:注入灵魂 (逻辑层)

本节目标: 专注于 逻辑实现。我们将编写 <script setup> 部分,定义表单的响应式数据模型、声明式校验规则,为后续的登录请求做好万全准备。

请打开 src/views/Login/index.vue 并用以下代码填充其 <script setup> 部分 (这将暂时替换为空的 <script>):

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
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useEventListener } from '@vueuse/core'

// 1. 获取 ElForm 组件实例,用于后续的统一校验
const formRef = ref<FormInstance>()

// 2. 表单数据模型
const formModel = reactive({
account: '3381292732@qq.com', // 预填入测试账号
password: '123456', // 预填入测试密码
agree: true,
})

// 3. 表单验证规则
const rules: FormRules = {
account: [
{ required: true, message: '请输入邮箱或手机号', trigger: 'blur' },
{
pattern: /^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|1[3-9]\d{9})$/,
message: '请输入正确的邮箱或手机号',
trigger: 'blur'
}
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为6-20位', trigger: 'blur' }
],
agree: [
{
validator: (_rule: unknown, value: boolean, callback: (error?: string) => void) => {
if (!value) {
callback('请同意用户协议')
} else {
callback()
}
},
trigger: 'change'
}
]
}

// 4. 动画元素引用
const peopleRef = ref<HTMLImageElement>()
const sphereRef = ref<HTMLImageElement>()

// (此处将插入登录逻辑)
// ...

// 5. 交互占位函数
const handleForgotPassword = () => ElMessage.info('忘记密码功能待开发')
const handleThirdPartyLogin = (type: string) => ElMessage.info(`${type}登录功能待开发`)
const handleNavClick = (item: string) => ElMessage.info(`${item}功能待开发`)

// 6. 动画处理
const handlePeopleAnimationEnd = () => {
if (peopleRef.value) {
peopleRef.value.classList.remove('p-animation')
peopleRef.value.classList.add('p-other-animation')
}
}
const handleSphereAnimationEnd = () => {
if (sphereRef.value) {
sphereRef.value.classList.remove('s-animation')
sphereRef.value.classList.add('s-other-animation')
}
}
useEventListener(peopleRef, 'animationend', handlePeopleAnimationEnd)
useEventListener(sphereRef, 'animationend', handleSphereAnimationEnd)
</script>

逻辑解读:

  • DOM 引用: 我们使用 ref() 创建了对模板中 ElForm 元素的引用,这对于后续调用其 validate() 方法至关重要。
  • 数据与规则: formModelrules 的定义是 ElForm 声明式校验的核心。我们将数据和校验规则分离,使代码清晰易懂。
  • 事件处理占位: 我们为页面上的所有可交互元素都绑定了 @click 事件,并链接到 <script> 中对应的 handle... 函数。目前这些函数只做提示,为下一步集成真实逻辑预留了清晰的入口。
  • useEventListener: 对于动画结束事件,我们使用了 VueUse 的 useEventListener。这是一个更优的实践,因为它会自动在组件卸载时销毁事件监听器,避免内存泄漏。

至此,我们已经拥有了一个外观精美、且具备完整表单校验逻辑的登录页面。


3.6 编码实现:useMutation 与登录逻辑闭环

本章目标: 我们将对 Login.vue<script setup> 部分进行最终改造。您将学习如何引入并使用 TanStack QueryuseMutation 来处理表单提交,并通过其强大的 onSuccessonError 回调,将 API 请求、Pinia 状态更新、消息提示和路由跳转等一系列操作优雅地串联起来。

1. 设计思路:为何使用 useMutation

技术选型
实现登录请求前

架构师,现在我要实现点击登录按钮后的逻辑了。最直接的想法就是在 handleLogin 函数里 await loginApi(),然后用 try/catch 分别处理成功和失败的逻辑,对吗?

完全正确,这是 Vue 开发的“标准答案”,它能解决问题。但专业的开发者会选择更合适的工具来处理这类“数据变更”操作。

有什么更优雅的办法吗?

这就是我们引入 TanStack QueryuseMutation 的原因。它能完美地将 “触发动作”“处理动作的副作用” 清晰地分离开来。

怎么个分离法?

组件的职责 变得极其简单:只负责在点击按钮后,调用 mutate 函数来 触发 登录动作。而 useMutation 的职责,是负责在后台 执行 异步请求,并根据请求结果(成功/失败),在 onSuccess / onError 回调中 处理所有后续的副作用——比如弹窗提示、更新 Store、页面跳转等。

我明白了!这样我的组件逻辑就非常干净,所有跟登录成功/失败相关的后续操作都内聚在了一起,维护起来一目了然!

2. 完整代码实现 (Login.vue)

我们将对 Login.vue<script setup> 部分进行最终的整合。

请打开 src/views/Login/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
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
<script setup lang="ts">
import { ref, reactive } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { useEventListener } from "@vueuse/core";
// 1. 引入所需的核心工具
import { useMutation } from "@tanstack/vue-query";
import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router";
import { loginApi } from "@/apis/user"; // 确保 loginAPI 已在 user.ts 中创建
const userStore = useUserStore();
const router = useRouter();

// 获取 ElForm 组件实例,用于后续的统一校验
const formRef = ref<FormInstance>();

// 表单数据模型
const formModel = reactive({
account: "",
password: "",
agree: true, // 同意协议,默认为 true
});

// 表单验证规则
const rules: FormRules = {
account: [
{ required: true, message: "请输入邮箱或手机号", trigger: "blur" },
{
pattern: /^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|1[3-9]\d{9})$/,
message: "请输入正确的邮箱或手机号",
trigger: "blur",
},
],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ min: 6, max: 20, message: "密码长度应为6-20位", trigger: "blur" },
],
agree: [
{
validator: (
_rule: unknown,
value: boolean,
callback: (error?: string) => void,
) => {
if (!value) {
callback("请同意用户协议");
} else {
callback();
}
},
trigger: "change",
},
],
};

// 动画元素引用
const peopleRef = ref<HTMLImageElement>();
const sphereRef = ref<HTMLImageElement>();

// 处理登录
// 2. 使用 useMutation 封装登录逻辑
const { mutate, isPending } = useMutation({
mutationFn: loginApi, // 指定实际执行的异步函数
onSuccess: (data) => {
// 登录成功后的回调
// 2.1 使用 ElMessage 显示成功提示
ElMessage({
message: "登录成功!",
type: "success",
showClose: true,
});

// 2.2 将用户信息和 Token 存入 Pinia
userStore.setUserInfo(data.result);

// 2.3 跳转到首页
router.push("/");
},
onError: (error) => {
// 登录失败的回调
ElMessage({
message: "登录失败,请检查账号密码",
type: "error",
showClose: true,
});
console.error("登录错误:", error);
},
});

// 处理登录表单提交
const handleLogin = async () => {
if (!formRef.value) return;

try {
// 验证表单
await formRef.value.validate();
// 调用 mutation 进行登录
mutate({
account: formModel.account,
password: formModel.password,
});
} catch {
ElMessage({
message: "请检查输入信息",
type: "error",
showClose: true,
});
}
};

// 处理忘记密码
const handleForgotPassword = () => {
ElMessage({
message: "忘记密码功能待开发",
type: "info",
showClose: true,
});
};

// 处理第三方登录
const handleThirdPartyLogin = (type: string) => {
ElMessage({
message: `${type}登录功能待开发`,
type: "info",
showClose: true,
});
};

// 处理导航点击
const handleNavClick = (item: string) => {
ElMessage({
message: `${item}功能待开发`,
type: "info",
showClose: true,
});
};

// 动画处理 - 使用 VueUse 优化
const handlePeopleAnimationEnd = () => {
if (peopleRef.value) {
peopleRef.value.classList.remove("p-animation");
peopleRef.value.classList.add("p-other-animation");
}
};

const handleSphereAnimationEnd = () => {
if (sphereRef.value) {
sphereRef.value.classList.remove("s-animation");
sphereRef.value.classList.add("s-other-animation");
}
};

// 使用 VueUse 的 useEventListener 自动处理事件监听和清理
useEventListener(peopleRef, "animationend", handlePeopleAnimationEnd);
useEventListener(sphereRef, "animationend", handleSphereAnimationEnd);
</script>

<template>
<div class="login-page">
<div class="login-content">
<!-- 左侧动画区域 -->
<div class="left-section">
<img
ref="peopleRef"
src="@/assets/login/img2.png"
class="people p-animation"
alt="people"
/>
<img
ref="sphereRef"
src="@/assets/login/img1.png"
class="sphere s-animation"
alt="sphere"
/>
</div>

<!-- 右侧表单区域 -->
<div class="right-section">
<!-- 顶部导航 -->
<div class="top-navigation">
<div class="nav-item" @click="handleNavClick('首页')">
<span class="nav-text">首页</span>
</div>
<div class="nav-item" @click="handleNavClick('注册')">
<span class="nav-text">注册</span>
</div>
</div>

<!-- 表单区域 -->
<div class="form-wrapper">
<h1 class="form-title">欢迎登录我们的平台</h1>

<el-form
ref="formRef"
:model="formModel"
:rules="rules"
@submit.prevent="handleLogin"
>
<el-form-item prop="account">
<el-input
v-model="formModel.account"
placeholder="请输入邮箱或手机号"
size="large"
clearable
/>
</el-form-item>

<el-form-item prop="password">
<el-input
v-model="formModel.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
clearable
/>
</el-form-item>

<el-form-item>
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
"
>
<el-form-item prop="agree" style="margin: 0">
<el-checkbox v-model="formModel.agree">
我同意用户协议和隐私政策
</el-checkbox>
</el-form-item>
<a
href="#"
class="forgot-password"
@click.prevent="handleForgotPassword"
>
忘记密码?
</a>
</div>
</el-form-item>

<button
type="submit"
class="login-button"
:disabled="isPending"
@click="handleLogin"
>
{{ isPending ? "登录中..." : "登录" }}
</button>
</el-form>

<!-- 第三方登录 -->
<div class="other-login">
<div class="divider">
<span class="line"></span>
<span class="divider-text">或使用以下方式登录</span>
<span class="line"></span>
</div>
<div class="other-login-wrapper">
<div
class="other-login-item"
@click="handleThirdPartyLogin('QQ')"
>
<img src="@/assets/login/QQ.png" alt="QQ" />
</div>
<div
class="other-login-item"
@click="handleThirdPartyLogin('微信')"
>
<img src="@/assets/login/WeChat.png" alt="WeChat" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<style lang="scss" scoped>
@use "@/styles/login.scss" as *;
</style>

3. 代码解读

  • 引入依赖: 我们引入了 useMutation, useUserStore, useRouterloginApi,这是完成登录闭环所需的所有“零件”。
  • useMutation 配置:
    • mutationFn: loginApi: 我们将实际的 API 请求函数 loginApi 告诉 useMutation
    • onSuccess(data): 当 loginApi 成功返回时(Promise resolved),这个回调会被执行。参数 data 就是 loginApi 返回的完整响应。我们在这里 按顺序执行了三个核心的副作用操作:弹窗提示、存入 Pinia、跳转页面。
    • onError(): 当 loginApi 失败时(Promise rejected),这个回调会被执行,我们在这里只做错误提示。
  • mutate 函数: 这是 useMutation 返回的用于 触发 异步操作的函数。我们在 handleLogin 中,当表单校验通过后,调用 mutateTanStack Query 就会自动用表单数据作为参数去执行 loginApi
  • isPending 状态: 这是 useMutation 返回的布尔值 ref。当 loginApi 正在执行时,isPendingtrue。我们已将其绑定到登录按钮的 :disabled 和文本上,提供了非常优秀的用户体验。

4. 端到端完整流程验证

现在,我们已经完成了整个登录流程的闭环。是时候进行一次完整的测试了。

  1. 启动服务: 确保您的前端 (pnpm run dev) 和 Mock 后端 (pnpm run mock) 都在运行。
  2. 访问页面: 打开浏览器,访问 http://localhost:5173/login
  3. 校验失败测试: 清空输入框,直接点击登录按钮,确认 ElForm 的校验提示正常出现。
  4. 登录失败测试: 输入错误的密码(例如 wrong_password),点击登录。确认按钮显示“登录中…”,然后 ElMessage 弹出“登录失败,请检查账号密码”的提示。
  5. 登录成功测试:
    • 使用预填的正确账号和密码 (3381292732@qq.com123456)。
    • 点击登录。
    • 观察: 按钮应显示“登录中…”。
    • 验证: 您应该会看到“登录成功!”的提示,页面应自动跳转到首页 (/)。
    • 验证 (持久化): 刷新首页。如果页面右上角依然显示用户名和“退出登录”按钮,证明我们的 Pinia 持久化已成功工作!
  6. Token 过期与无感刷新测试:
    • 登录成功后,等待 1 分钟(我们在后端设置的 accessToken 过期时间)。
    • 在首页,尝试 刷新页面
    • 观察: 页面可能会有极短暂的加载过程,但最终你会发现你 依然保持登录状态!打开开发者工具的“网络”面板,你会看到一次对 /token/refresh 的请求和一次对 /categories (或其他首页 API) 的请求。这证明我们的 Axios 拦截器成功实现了无感刷新!

3.7 模块提交与总结

至此,我们已经成功构建了一套完整的、企业级的用户认证与授权体系。从支持双 Token 的后端接口,到具备无感刷新能力的 Axios 拦截器,再到与 useMutation 结合的健壮登录流程,我们的应用现在拥有了坚实的安全基石。

现在,是时候将本模块的成果作为一个重要的里程碑,提交到我们的版本库了。

当前任务: 3.7 - 模块成果提交
任务目标: 将模块三中完成的完整用户认证流程,作为一个核心功能节点提交到 Git 仓库。

命令行操作

请打开终端,并执行以下命令:

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

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

    1
    git commit -m "feat(auth): implement full user authentication with dual-token refresh"

    Commit Message 解读:

    • feat: 表示这是一个新功能 (feature) 的提交。
    • (auth): 指明了本次提交的核心范围是“认证” (authentication) 模块。
    • implement full user authentication with dual-token refresh: 简明扼要地描述了我们完成的具体工作:实现了完整的用户认证及双 Token 无感刷新机制。

提交成功后,您的项目就有了一个清晰的、代表“用户认证功能开发完成”的历史节点。我们已经准备好带着一个安全的“身份”,去探索后续更复杂的业务模块了。


模块四:分类聚合 - 构建动态商品列表页

本章概述: 欢迎来到模块四。在本章,我们将从“全局视野”转向“业务深耕”,构建电商应用的核心——商品分类聚合页。你将学习如何利用 Vue Router 的动态路由 捕获用户意图,并首次实践 组合式函数 (Composable) 这一 Vue 3 的核心思想,将复杂的、可复用的业务逻辑从组件中优雅地解耦出来。最终,我们将结合 Element Plus 的面包屑导航,打造一个数据驱动、高度可维护的二级分类页面。

img

经过前三个模块的洗礼,相信您已经完全掌握了项目的工程化基石、全局布局搭建和核心认证流程。

本模块核心学习目标:

  1. 【核心】掌握组合式函数 (Composables) 封装: 学习如何将“数据获取逻辑”(API 调用、路由依赖、响应式状态管理)封装到独立的 useCategory.ts 文件中,实现业务逻辑的 高内聚和可复用性
  2. 实践关注点分离: 通过创建 useBanneruseCategory,让 Category/index.vue 组件的职责回归到纯粹的 “组织和呈现”,而不是“获取和处理数据”。
  3. 精通动态路由参数的安全处理: 在 Composable 内部优雅地处理 id 的变化和边界情况,让组件对此“无感”。
  4. 构建数据驱动的 UI: 学习使用 ElBreadcrumb 等组件,并消费来自 Composables 的数据,构建数据驱动的用户界面。

本模块任务清单

任务模块任务名称核心目标与学习要点
【核心】逻辑层封装创建 useCategory Composable将“获取单个分类数据”的逻辑,从组件中抽离并封装成一个独立的、响应式的 useCategory 组合式函数。
【核心】逻辑层封装创建 useBanner Composable将“获取分类页 Banner”的逻辑也封装成独立的 useBanner 函数,进一步实践逻辑解耦。
视图层构建Category 页面的组装构建 Category/index.vue,使其只负责调用 useCategoryuseBanner,并将返回的响应式数据传递给子组件进行渲染。
UI 组件实践动态面包屑导航与 Banner使用 Element Plus 组件,并消费来自 Composables 的数据,构建数据驱动的面包屑和 Banner。
业务组件封装商品列表与 GoodsItem 组件封装 GoodsItem.vue,并在 Category/index.vue 中渲染商品列表。
模块提交提交模块成果提交一个结构清晰、逻辑解耦的分类页模块。

4.1 Mock 体系终极进化:从文件系统到动态 API

在真实的项目开发中,我们面对的往往不是一个干净的 JSON 文件,而是一个由成百上千个文件和文件夹构成的、复杂的资源库。为了让我们的教程无限贴近实战,本节我们将对 Mock 体系进行一次终极进化。

我们将编写一个高度智能的 generate-data.cjs 脚本。它将扮演“数据工程师”的角色,自动扫描 public/data 目录下复杂的、按品类组织的真实文件结构,动态地聚合出结构化的分类、子分类、商品详情和 Banner 数据。最终,这些“热气腾腾”的数据将被 server.cjs 接管,为我们提供一套强大而真实的 API。

当前任务: 4.1 - Mock 体系终极进化
任务目标: 深入理解并实现一个能遍历文件系统、动态聚合数据的 generate-data.cjs 脚本,并配合 server.cjs,将真实的文件资源转化为可供前端消费的 RESTful API。

1. 设计思路:让代码去适应数据

Mock 终极架构
模块四开发前

架构师,我现在的商品数据都按品类、子品类、产品型号分文件夹存在 public/data 里了,非常零散。我该怎么让 json-server 理解这种复杂的结构?

架构师

这正是企业级项目管理资源的方式!我们绝对不应该手动把它们合并成一个巨大的 JSON。正确的做法是“让代码去适应数据”。

怎么做呢?

架构师

我们将赋予 generate-data.cjs 真正的“智能”。它将不再只是一个简单的文件读取器,而是一个“数据爬虫”。它会深入到 public/data 的每一层目录,读取 product_info.json,识别封面图和详情图,然后将这些零散的信息,按照我们前端最希望的 API 格式(比如 categories -> subCategories -> products),组装成一个完美的内存数据结构。

这样一来,我们的数据维护就变得极其简单了!只需要在 data 目录里增删文件夹,然后重启 Mock 服务,API 就会自动更新!

架构师

完全正确!我们把复杂性封装在了数据生成脚本中,换来的是数据维护的便利性和前端开发的愉悦。接下来,server.cjs 只需要为这些动态生成的数据,提供几个便捷的访问接口即可。

2. 核心代码实现与深度解读

第一部分:智能数据聚合器 (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
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
// 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 readCategoryData = (categoryId) => {
const categoryPath = path.join(__dirname, `../public/data/${categoryId}`);
if (!fs.existsSync(categoryPath)) return [];

const subCategories = [];
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 products = [];

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 coverDir = path.join(productPath, "cover");
const detailDir = path.join(productPath, "detail");

let coverImage = "";
let detailImages = [];

if (fs.existsSync(coverDir) && fs.existsSync(detailDir)) {
// 使用分离后的目录结构
const coverFiles = fs
.readdirSync(coverDir)
.filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file))
.sort();

const detailFiles = fs
.readdirSync(detailDir)
.filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file))
.sort();

// 取第一张封面图
coverImage = coverFiles.length > 0 ? coverFiles[0] : "";

// 所有详情图
detailImages = detailFiles;
} else {
// 使用原始的单目录结构(向后兼容)
const images = fs
.readdirSync(productPath)
.filter((file) => /\.(jpg|jpeg|png|gif)$/i.test(file))
.sort();

const foundCoverImage =
images.find((img) => img.startsWith("00_cover")) || images[0];
coverImage = foundCoverImage || "";

detailImages = images.filter((img) => !img.startsWith("00_cover"));
}

products.push({
id: faker.string.uuid(),
name: productInfo.name || productDir.name,
desc: productInfo.tip || "",
coverImage: coverImage
? fs.existsSync(coverDir)
? `/data/${categoryId}/${item.name}/${productDir.name}/cover/${coverImage}`
: `/data/${categoryId}/${item.name}/${productDir.name}/${coverImage}`
: "",
detailImages: detailImages.map((img) =>
fs.existsSync(detailDir)
? `/data/${categoryId}/${item.name}/${productDir.name}/detail/${img}`
: `/data/${categoryId}/${item.name}/${productDir.name}/${img}`
),
// 只保留需要的字段,避免污染数据
url: productInfo.url,
tip: productInfo.tip,
category_id: productInfo.category_id,
category_name: productInfo.category_name,
tab_name: productInfo.tab_name,
});
}

subCategories.push({
id: faker.string.uuid(),
name: item.name,
products: products,
});
}
}

return subCategories;
};
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
const categories = staticData.categories.map((category) => ({
...category,
banners: readCategoryBanners(category.id),
subCategories: readCategoryData(category.id),
}));

const data = {
users: [],
categories: categories,
// 全局 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;
};


generate-data.cjs 核心代码深度解读

这么长的代码,我们不能囫囵吞枣。让我们把它拆解成三个核心部分来理解:两个工具函数一个主流程

核心解读 1:readCategoryBanners 函数 - 专职的 Banner 搜集器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 简单的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 [];
};
  • 它的作用是什么?
    这是一个高度复用的工具函数,专门负责读取指定分类ID下的 banner 文件夹。
  • 它是如何工作的?
    1. path.join(...): 使用 Node.js 的 path 模块,安全地拼接出目标 banner 文件夹的绝对路径。
    2. fs.existsSync(bannerPath): 防御性编程。在读取前,先检查该目录是否存在,避免程序因找不到目录而崩溃。
    3. fs.readdirSync(bannerPath): 读取目录下的所有文件名。
    4. .filter(...): 使用正则表达式,只保留 .jpg, .png 等图片文件,过滤掉其他无关文件(如 .DS_Store)。
    5. .map(...): 将过滤后的文件名数组,加工 成前端组件真正需要的、包含 id, imgUrl, hrefUrl 的对象数组结构。

核心解读 2:readCategoryData 函数 - 深入骨髓的数据矿工

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 读取分类的子分类和产品
const readCategoryData = (categoryId) => {
const categoryPath = path.join(__dirname, `../public/data/${categoryId}`);
if (!fs.existsSync(categoryPath)) return [];

const subCategories = [];
const items = fs.readdirSync(categoryPath, { withFileTypes: true });

for (const item of items) {
if (item.isDirectory() && !["banner", "Banner"].includes(item.name)) {
// ... (内部逻辑)
}
}
return subCategories;
};
  • 它的作用是什么?
    这是脚本的“心脏”,负责深入一个分类目录(如 airConditioner),挖掘出其中所有的子分类(如“挂式空调”)和子分类下的所有商品信息。
  • 它是如何工作的?
    这是一个 双重循环 结构:
    1. 外层循环: for (const item of items),它遍历一级分类下的所有目录,并通过 item.isDirectory()!["banner", ...] 的判断,精确地找出所有 子分类目录
    2. 内层循环: 在找到子分类目录后,它会再次进入该目录,遍历并找出所有的 产品目录(如“U铂”)。
    3. 智能图片处理: 在产品目录中,它会自动识别以 00_cover 开头的图片作为封面 (coverImage),其余作为详情图 (detailImages)。这是一种非常常见的工程约定,让数据维护变得简单。
    4. 数据合并: 它将从 product_info.json 读取的文本信息、自动生成的 id 和随机价格、以及识别出的图片路径,最终合并成一个完整的商品对象,并层层向上聚合。

核心解读 3:module.exports - 运筹帷幄的总指挥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
const categories = staticData.categories.map((category) => ({
...category,
banners: readCategoryBanners(category.id),
subCategories: readCategoryData(category.id),
}));

const data = {
// ...
categories: categories,
// ...
};

// ... (生成用户数据)

return data;
};
  • 它的作用是什么?
    这是脚本的主入口,负责编排整个数据生成流程。
  • 它是如何工作的?
    1. 它首先加载 mock-data.json,将其作为顶级分类的“骨架”(只包含 idname)。
    2. 然后通过 .map() 遍历这个骨架,为每一项调用我们上面分析过的 readCategoryBannersreadCategoryData 工具函数。
    3. 这个过程就像是为骨架填充“血肉”,将从文件系统动态读取到的真实数据,注入到每个分类对象中。
    4. 最终,它返回一个包含了所有聚合数据的、结构完美的 data 对象,供 json-server 使用。

第二部分:便捷的 API 服务员 (server.cjs)

数据准备好后,我们需要 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
// 自定义 /categories 路由以符合 OpenAPI 规范
server.get("/categories", (req, res) => {
const db = router.db;
const categories = db.get("categories").value();

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

// 新增:自定义 /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,
});
});

// 特殊的Banner接口 - 方便前端直接获取分类Banner
server.get("/category/:categoryId/banners", (req, res) => {
const { categoryId } = req.params;
const db = router.db;
const categories = db.get("categories").value();
const category = categories.find((cat) => cat.id === categoryId);

res.status(200).json({
code: "200",
msg: "操作成功",
result: category?.banners || [],
});
});

代码深度解读:

  • 保留认证逻辑: 我们完整地保留了模块三中实现的认证相关代码,确保登录功能不受影响。
  • GET /category/:categoryId/banners:
    • 作用: 这是一个“便捷接口”。前端在分类页只需要知道当前分类的 ID,就可以通过这个接口轻松获取到对应的 Banner 数据,而无需请求整个庞大的分类对象,大大减少了网络传输的数据量。
    • 实现: 它利用 req.params 获取 URL 中的动态段 :categoryId,然后在 json-server 的内存数据库 (router.db) 中进行查找,并返回我们已在 generate-data.cjs 中动态附加的 banners 数组。
  • 默认路由的威力: 我们没有为 GET /categories/:id 编写任何自定义代码!json-server 会自动处理这个请求,直接从 generate-data.cjs 生成的数据中,找到 categories 数组里 id 匹配的项并返回。这就是约定优于配置的力量。

3. 验证与展望

  1. 准备数据: 确保你已将完整的 public/data 目录结构和 mock/mock-data.json 基础文件准备好。
  2. 启动服务: 在终端中重启 Mock Server (pnpm run mock)。
  3. 测试接口:
    • http://localhost:3001/categories/new: 访问这个由 json-server 自动提供 的接口。你应该能看到一个完整的 JSON 对象,包含了“家用空调”的所有信息,包括动态读取到的 bannerssubCategories
    • http://localhost:3001/categories/new/banners: 访问我们手动创建的便捷接口。你应该能看到只包含 Banner 信息的 result 数组。

我们的 Mock 体系现已进化到最终形态。它既能利用 json-server 的自动化能力,又能通过自定义脚本和路由处理复杂的、真实世界的数据结构。我们已经为后续所有模块的开发铺平了道路。


4.2 【核心】封装与 TanStack Query 结合的业务 Composable

我们已经有了一个强大的、能反映真实文件结构的 Mock API。现在,是时候构建消费这个 API 的前端逻辑了。我们将直面一个核心问题:“如何将依赖路由的、由 TanStack Query 管理的异步数据逻辑,从组件中优雅地剥离出来?”

答案是:useQuery 本身封装到组合式函数 (Composable) 中。

当前任务: 4.2 - 封装 useCategory Composable
任务目标: 创建 types/category.ts, apis/category.ts,然后将所有与获取单个分类数据相关的逻辑(包括 useQuery 的调用),封装到一个独立的、可复用的 composables/useCategory.ts 文件中。

1. 设计思路:Composable 的真正力量

架构演进
开始构建 Category.vue 之前

架构师,我的 Category.vue 组件需要根据路由 id 获取分类信息。按照之前的经验,我应该在组件里用 useRoute 获取 id,然后把它传给 useQuery

架构师

这是一种直接的方法。但设想一下,如果“推荐商品”组件也需要根据这个 id 获取一些关联数据,我们是不是要在两个组件里都重复写一遍 useRouteuseQuery 的逻辑?

没错,这会导致代码重复。

架构师

所以,我们要更进一步。我们将创建一个 useCategory.ts Composable,它的职责不仅仅是调用 API,而是完整地封装与“获取分类数据”相关的所有逻辑,包括 useQuery 本身

那么,useQueryqueryKey 怎么响应路由 id 的变化呢?

架构师

这正是 TanStack Query 的优雅之处。我们将在 Composable 内部使用 useRoute,并将路由 id 作为一个 响应式computed 值。然后把这个 computed 值放入 queryKey 数组中。当路由变化时,queryKey 就会自动变化,TanStack Query自动 为我们重新获取数据。我们甚至不再需要手动 watch

哇!所以组件只需要 const { categoryData, isLoading } = useCategory() 这一行,就能得到所有响应式的状态,而 Composable 内部用 useQuery 已经处理好了一切,包括路由变化时的自动重新请求?

架构师

完全正确! 这就是 Composable 与 TanStack Query 结合的终极形态——组件极简,逻辑内聚且高度自动化。

2. 创建类型与 API (适配新数据结构)

在封装 Composable 之前,我们必须先定义好它所依赖的“工具”。这次,我们的类型和 API 将严格依据 4.1 节中 generate-db.js 生成的 db.json 结构来编写。

2.1 创建 src/types/category.ts

这个文件将为我们所有与分类、商品相关的数据提供类型安全保障。

请打开或创建 src/types/category.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
// src/types/category.ts

// 产品信息接口
export interface Product {
id: string;
name: string;
desc: string;
coverImage: string; // 封面图片
detailImages: string[]; // 详情图片数组
tip?: string; // 产品提示信息
[key: string]: any; // 允许其他动态属性
}

// 子分类接口
export interface SubCategory {
id: string;
name: string;
products: Product[]; // 该子分类下的产品列表
}

// 一级分类项
export interface CategoryItem {
id: string;
name: string;
icon?: string;
banners?: BannerItem[]; // 分类专属的轮播图
subCategories?: SubCategory[]; // 子分类列表
products?: Product[]; // 兼容原始数据结构中的直接产品列表
}

// Banner 项
export interface BannerItem {
id: string;
imgUrl: string;
hrefUrl: string;
type?: string; // 根据 mock 数据,这个字段可能存在
}

2.2 创建 src/apis/category.ts

这个文件将统一管理所有与分类页面相关的 API 请求。

请打开或创建 src/apis/category.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
// src/apis/category.ts
import httpInstance from "@/utils/http";
import type { CategoryItem, BannerItem } from "@/types/category";

/**
* @description: 获取单个顶级分类数据 (包含子分类和轮播图)
* @param {String} id - 顶级分类ID
* @return {Promise<CategoryItem>}
*/
export const getCategoryItemAPI = async (id: string): Promise<CategoryItem> => {
// 通过 json-server 获取包含 banners 和 subCategories 的完整分类数据
// httpInstance 响应拦截器会自动处理响应数据
return (await httpInstance.get(`/categories/${id}`)) as CategoryItem;
};

/**
* @description: 获取分类页 Banner (为了简便使用,不在代码中进行提取,所以单独提供一个接口,现在 banners 包含在分类数据中)
* @param {String} id - 顶级分类ID
* @return {Promise<{ result: BannerItem[] }>}
*/
export const getCategoryBannerAPI = async (
id: string
): Promise<{ result: BannerItem[] }> => {
return (await httpInstance.get(`/category/${id}/banners`)) as {
result: BannerItem[];
};
};

3. 创建 useCategory Composable

现在,我们来构建核心的 Composable,它将 useQuery 的强大功能封装其中。

请在 src/views/Category/composables/ 目录下创建 useCategory.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
// src/views/Category/composables/useCategory.ts
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useQuery } from "@tanstack/vue-query";
import { getCategoryItemAPI } from "@/apis/category";

export function useCategory() {
const route = useRoute();
// 使用 computed 确保 categoryId 是响应式的
const categoryId = computed(() => route.params.id as string);

// 使用 TanStack Query 来获取数据
const {
data: categoryData,
isLoading,
isError,
} = useQuery({
// queryKey 必须是一个数组,并且包含响应式的数据源
// 当 categoryId.value 变化时,TanStack Query 会自动重新执行查询
queryKey: ["categoryItem", categoryId],

// queryFn 接收一个包含 queryKey 的上下文对象
queryFn: ({ queryKey }) => {
// queryKey[1] 就是 categoryId.value
const id = queryKey[1] as string;
return getCategoryItemAPI(id);
},

// enabled 选项用于控制查询是否执行
// 只有当 categoryId.value 存在 (truthy) 时,查询才会触发
enabled: computed(() => !!categoryId.value),

// TanStack Query 默认不返回 { result: ... } 结构
// 我们在这里保持 API 的原始返回,因为拦截器已处理了 .data
// 如果需要转换,可以使用 `select` 选项
});

return {
categoryData,
isLoading,
isError,
};
}

4. 深度解读与优势

  • watch 的消失: 最大的变化是什么?我们不再需要 onMountedwatchuseQuery 通过监听 queryKey 中响应式数据 (categoryId) 的变化,自动地 处理了首次加载和后续更新的所有情况。这极大地简化了代码。
  • 响应式 queryKey: queryKey: ['topCategory', categoryId] 是这里的核心。它告诉 TanStack Query:“这个数据的身份不仅是‘顶级分类’,还跟当前的 categoryId 息息相关。只要 categoryId 变,它就是一个全新的、需要重新获取的数据。”
  • 健壮性 (enabled): enabled: computed(() => !!categoryId.value) 是一个企业级的健壮性实践。它确保了只有在 id 真实存在于 URL 中时,我们才去发起网络请求,避免了无效调用。
  • 逻辑内聚与组件解耦: 所有的复杂性——路由依赖、响应式键、API调用、加载和错误状态管理——都被完美封装。Category.vue 组件将对此一无所知。

4.3 封装第二个 Composable: useBanner (同样使用 TanStack Query)

为了巩固练习,并保持代码风格的绝对一致,我们将用同样强大的模式来封装 useBanner

当前任务: 4.3 - 封装 useBanner Composable
文件路径: src/views/Category/composables/useBanner.ts
任务目标: 创建一个 useBanner 组合式函数,它同样使用 TanStack Query 和响应式 queryKey 来获取特定分类的 Banner 数据。

1. 编码实现

请在 src/views/Category/composables/ 目录下,创建一个新文件 useBanner.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
// src/views/Category/composables/useBanner.ts
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useQuery } from "@tanstack/vue-query";
import { getCategoryBannerAPI } from "@/apis/category";
import type { BannerItem } from "@/types/category";

export function useBanner() {
const route = useRoute();
const categoryId = computed(() => route.params.id as string);

const {
data: bannerData,
isLoading,
isError,
} = useQuery({
queryKey: ["categoryBanner", categoryId],
queryFn: ({ queryKey }) => {
const id = queryKey[1] as string;
return getCategoryBannerAPI(id);
},
select: (data) => data.result,
enabled: computed(() => !!categoryId.value),
});

return {
bannerData,
isLoading,
isError,
};
}

2. 代码解读

  • 模式复用: 我们应用了与 useCategory 完全相同的模式,这使得代码库的可预测性大大增强。
  • select 选项的应用: 在这里我们展示了 useQueryselect 功能。我们的 getCategoryBannerAPI 返回 { result: [...] } 结构,通过 select: (data) => data.result,我们告诉 TanStack Query:“请在数据获取成功后,只把 result 属性的值返回给 bannerData”。这让组件拿到的数据更纯净。

现在,我们拥有了两个高度内聚、功能强大、且完全自动化的数据获取 Composable。在下一节,我们将把它们“插”入到 Category.vue 组件中,见证最终的优雅与简洁。


4.4 视图组装:构建分类页的骨架

我们已经将所有的数据获取逻辑封装到了 Composables 中。现在,我们的 Category.vue 组件可以放下“如何获取数据”的包袱,全身心地投入到它最核心的职责中:构建用户界面

在这一节,我们将完成 Category.vue 页面的静态布局搭建。我们将引入所有必要的子组件,并使用 Element Plus 提供的布局和 UI 组件,结合少量的自定义 SCSS,将设计稿转化为一个视觉上完整、结构清晰的静态页面。

当前任务: 4.4 - 视图组装
任务目标: 搭建 Category.vue 的完整静态模板,包括面包屑、Banner、子分类导航和商品列表展示区。重点实践 Element Plus 组件的应用和 BEM 规范的 SCSS 编写。

1. 规划组件结构

分析设计稿(和你提供的 HTML 代码),我们可以将分类页主体内容拆分为以下几个部分:

  1. 面包屑导航: 显示用户当前的位置路径。
  2. Banner 轮播图: 展示该分类下的广告图。
  3. 子分类列表: 展示该顶级分类下的所有二级分类。
  4. 分区的商品列表: 按二级分类,分别展示其下的热门商品。

2. 搭建模板 (<template>)

我们将使用 v-if 来确保只在数据加载完成后才渲染页面内容,这可以防止访问 nullundefined 导致的错误。

请用以下完整代码,彻底替换 src/views/Category/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
<script setup lang="ts">
// 1. 引入我们的组合式函数
import { useCategory } from './composables/useCategory'
import { useBanner } from './composables/useBanner'
// 后续将引入商品组件
import GoodsItem from './components/GoodsItem.vue' // 假设已创建

// 2. 调用函数,获取所有需要的数据和状态
const { categoryData } = useCategory()
const { bannerData, isLoading: isBannerLoading } = useBanner()
</script>

<template>
<div class="category-page">
<div class="container">
<!-- 1. 面包屑导航 -->
<el-breadcrumb class="category-page__breadcrumb" separator=">" v-if="categoryData">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ categoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>

<!-- 2. Banner 轮播图 -->
<el-skeleton :loading="isBannerLoading" animated>
<template #template>
<el-skeleton-item variant="image" class="category-page__banner-skeleton" />
</template>
<template #default>
<el-carousel class="category-page__banner" height="500px" :arrow="'never'">
<el-carousel-item v-for="item in bannerData" :key="item.id">
<img :src="item.imgUrl" :alt="item.id">
</el-carousel-item>
</el-carousel>
</template>
</el-skeleton>

<!-- 3. 子分类与商品列表 -->
<div class="category-page__content" v-if="categoryData">
<div class="category-page__sub-title">
<h3>全部分类</h3>
</div>

<!-- 4. 按子分类展示商品 -->
<div class="category-page__goods-section" v-for="sub in categoryData.subCategories" :key="sub.id">
<div class="goods-section__header">
<h3>- {{ sub.name }} -</h3>
</div>
<div class="goods-section__body">
<!-- 这里我们将使用一个可复用的 GoodsItem 组件 -->
<GoodsItem v-for="product in sub.products" :key="product.id" :goods="product" />
</div>
</div>
</div>
</div>
</div>
</template>

模板代码解读:

  • 加载状态处理: 我们为面包屑和 Banner 都包裹了 el-skeleton 组件,并将其 :loading 属性与各自 Composable 返回的 isLoading 状态绑定。这提供了一个非常优雅的加载占位效果,是现代 Web 应用提升用户体验的标准实践。
  • BEM 规范: 所有的 class 都遵循了严格的 BEM 命名规范,例如 category-page__breadcrumbsub-list__item。这使得我们的 CSS 结构清晰,易于维护,且不会产生全局污染。
  • 组件化思想: 商品列表的渲染被抽象为 <GoodsItem> 组件。我们将在下一节创建这个组件。这种组件化的方式使得 Category.vue 的模板保持了高度的结构化,只负责布局和循环,而将单个商品如何展示的具体细节交给了 GoodsItem

3. 编写样式 (<style>)

现在,我们为这个结构添加样式。我们将尽量利用 Element Plus 的默认样式,只对布局和特定视觉效果进行自定义。

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

<style lang="scss" scoped>
.category-page {
width: 100vw;
min-height: 100vh;
margin: 0;
padding: 0;

.container {
width: 100%;
margin: 0;
padding: 0;
position: relative;
}

&__breadcrumb {
margin: 20px;
margin-bottom: 20px;
}

&__banner-skeleton {
width: 100%;
height: 500px;
}

&__banner {
width: 100%;
height: 500px;
z-index: 98; // 确保在某些布局下层级正确

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

&__content {
margin-top: 20px;
background-color: #fff;
padding: 20px;
width: 100%;
box-sizing: border-box;
}

&__sub-title {
h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 100px;
}
}

&__item-img {
width: 100px;
height: 100px;
}

&__item-name {
line-height: 1.5;
color: #666;
}
}

.goods-section {
&__header {
padding: 40px 0;

h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 1.2;
}
}

&__body {
display: flex;
flex-wrap: wrap;
gap: 15px;
justify-content: flex-start;
width: 100%;
}
}
</style>

样式代码解读:

  • Flexbox 布局: 我们大量使用了 Flexbox 来实现子分类和商品列表的横向排列和自动换行 (flex-wrap: wrap),并通过 gap 属性来创建均匀的间距。这是现代 CSS 布局的首选方案。
  • 最小化覆盖: 注意,我们没有去覆盖 el-breadcrumbel-carousel 的内部样式。我们利用了它们提供的 props (如 separator, height) 和默认的 class,只编写我们自己的布局和装饰性样式。这使得未来升级 Element Plus 版本时,我们的代码更加稳健。

至此,我们已经完成了一个视觉完整、结构清晰的静态分类页面。它已经准备好在下一节中,通过实现 <GoodsItem> 组件和注入真实数据,来完成最终的动态化。


4.5 业务组件封装:构建可复用的 GoodsItem

在任何电商应用中,“商品卡片”都是出现频率最高、最重要的基础组件之一。在这一节,我们将把单个商品的展示逻辑,封装成一个独立的、可复用的 GoodsItem.vue 组件。

遵循“单一职责原则”,Category.vue 负责布局,而 GoodsItem.vue 则专注于如何优雅地展示一个商品的信息

当前任务: 4.5 - 封装 GoodsItem 组件
任务目标: 创建一个 GoodsItem.vue 组件,它接收一个 product 对象作为 prop,并使用 Element Plus 的 ElCard 组件来构建一个包含图片、名称、描述和价格的、具有统一视觉风格的商品卡片。

1. 设计思路:从原子组件到页面

我们采用的是“原子设计”的思想。GoodsItem 就是一个“原子”,它很小,只关心一件事。然后我们用这些“原子”来组成“分子”(如商品列表 goods-section__body),最终构成一个完整的“生物体”(Category.vue 页面)。

这样做的好处是:

  • 高度复用: GoodsItem 组件未来可以在首页、搜索结果页、购物车推荐等任何需要展示商品的地方被直接复用。
  • 易于维护: 如果未来需要修改所有商品卡片的样式(比如增加一个“新品”标签),我们只需要修改 GoodsItem.vue 这一个文件。

2. 创建 GoodsItem.vue 组件

首先,我们需要在 Category 视图下创建一个 components 目录,用于存放其专属的子组件。

请在 src/views/Category/ 目录下,创建一个 components 文件夹,并在其中创建 GoodsItem.vue 文件。

1
2
3
4
5
6
7
src/
└── views/
└── Category/
├── composables/
├── components/
│ └── GoodsItem.vue <-- 新建此文件
└── index.vue

3. 编码实现 (GoodsItem.vue)

我们将使用 Element Plus 的 ElCard 作为卡片的基础容器,因为它自带了阴影、边框和内边距,能让我们快速实现专业的设计效果。

请在 src/views/Category/components/GoodsItem.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
<script setup lang="ts">
import type { Product } from '@/types/category';

defineProps<{
goods: Product
}>()
</script>

<template>
<RouterLink :to="`/product/${goods.id}`" class="goods-item">
<el-card class="goods-item__card" shadow="hover">

<!-- 【核心】使用 Element Plus 的 ElImage 组件 -->
<el-image :src="goods.picture || goods.coverImage" :alt="goods.name" lazy class="goods-item__image">
<!-- #placeholder 插槽:自定义加载时的占位内容 -->
<template #placeholder>
<div class="image-slot">
加载中<span class="dot">...</span>
</div>
</template>
<!-- #error 插槽:自定义加载失败时的内容 -->
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>

<div class="goods-item__info">
<p class="goods-item__name ellipsis">{{ goods.name }}</p>
<p class="goods-item__desc ellipsis">{{ goods.desc }}</p>
</div>
</el-card>
</RouterLink>
</template>

<style lang="scss" scoped>
.goods-item {
display: block;
flex: 1;
min-width: 200px;
max-width: 250px;
text-align: center;
text-decoration: none;
transition: all .5s;

&__card {
--el-card-padding: 15px;
}

&:hover {
transform: translate3d(0, -3px, 0);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
}

&__image {
width: 160px;
height: 160px;
}

&__info {
padding-top: 10px;
}

&__name {
font-size: 16px;
color: #333;
}

&__desc {
color: #999;
height: 29px;
}

&__price {
color: $priceColor;
font-size: 20px;
}
}

// 通用工具类,用于单行文本溢出显示省略号
.ellipsis {
@include truncate-text;
}

// ElImage 组件插槽的统一样式
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: #f5f5f5;
color: #909399;
font-size: 14px;
}
</style>

代码解读:

  • defineProps: 我们使用了 <script setup> 的编译时宏 defineProps 来声明组件期望接收一个名为 goods 的 prop,其类型为我们之前定义的 Product 接口。这提供了强大的类型检查和自动补全。
  • ElCard 组件: 我们直接使用了 el-card 并通过 shadow="hover" prop 轻松实现了鼠标悬浮时的阴影效果。我们还通过 CSS 变量 --el-card-padding 对其默认内边距进行了微调,这是 Element Plus 推荐的定制方式之一。
  • 图片懒加载 (v-img-lazy): 注意 <img> 标签上的 v-img-lazy 指令。这是一个非常重要的性能优化实践。我们假设在模块一或模块二中,已经全局注册了一个图片懒加载指令(例如使用 vue-lazyload@vueuse/lazy)。它能确保只有当图片滚动到可视区域时,才会真正发起网络请求加载,极大地提升了长列表页面的首屏加载速度。
  • RouterLink: 整个卡片被 RouterLink 包裹,并指向一个未来的商品详情页路径,如 /product/xxxx。这确保了整个卡片都是可点击的。
  • 原子化 CSS: 我们定义了一个 .ellipsis 工具类。这种将常用样式片段(如文本溢出)抽象为独立类的做法,被称为原子化 CSS 或功能类,可以提高样式的复用性。

4. 最终验证

  1. 重启开发服务器 (pnpm run dev)。
  2. 再次访问任意一个分类页面,例如 http://localhost:5173/category/airConditioner
  3. 预期效果:
    • 页面不再因为找不到 GoodsItem 组件而报错。
    • 在“挂式空调”、“立柜式空调”等子分类下方,你会看到由 ElCard 渲染出的、样式精美的商品卡片列表。
    • 当你将鼠标悬浮在某个商品卡片上时,应该能看到平滑的上浮和阴影效果。
    • 点击任意一个商品卡片,URL 会跳转到对应的商品详情页路径(尽管我们还没创建这个页面,但跳转行为本身证明了 RouterLink 正常工作)。

至此,我们已经完成了分类页面的所有核心静态布局和组件封装。我们的代码结构清晰、逻辑解耦、高度可复用。在下一节,我们将进行最后的收尾工作,并提交本模块的成果。


4.6 模块提交与总结

至此,我们已经成功地完成了 vue3-webShop 项目的第二个核心业务模块——商品分类页。

我们不仅仅是简单地渲染了一个页面,更重要的是,我们深入实践了 Vue 3 最核心的工程化思想

  • 逻辑解耦: 通过将业务逻辑封装到 Composable 中,我们让 Category.vue 组件变得极其纯净和可维护。
  • 组件化: 我们将 UI 拆分成了独立的、可复用的单元,如 GoodsItem.vue
  • 性能优化: 我们利用 ElImagelazy 属性,兵不血刃地实现了图片懒加载,确保了页面的高性能。

现在,是时候将我们本模块的成果提交到版本库了。

当前任务: 4.6 - 模块成果提交
任务目标: 将模块四中完成的所有分类页相关功能,作为一个完整的特性提交到 Git 仓库。

命令行操作

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

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

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

    1
    git commit -m "feat(category): build dynamic category page with composables"

    Commit Message 解读:

    • feat: 表示这是一个新功能 (feature) 的提交。
    • (category): 指明了本次提交影响的主要范围是“分类”模块。
    • build dynamic category page with composables: 简明扼要地描述了我们完成的具体工作:使用组合式函数 (Composable) 构建了动态的分类页面。

提交成功后,您的项目就有了一个清晰的、代表“分类页功能完成”的历史节点。我们已经准备好进入下一个更具挑战性的模块:商品详情页,去探索更复杂的 SKU 选择和数据交互。

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


本模块任务清单

任务模块任务名称核心技术应用与学习要点
后端与数据准备利用现有 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 作为单一事实来源带来的巨大便利。