详情模块

108 阅读8分钟

Axios 类型封装🚨

目标:改写 Axios 返回值的 TS 类型

Axios 二次封装,让 AxiosTS 类型组合使用时更方便。

Axios 内置类型声明解读

// 1. Axios 实例类型
export class Axios {
  // ...省略
  request<T = any, R = AxiosResponse<T>>(config): Promise<R>;
  get<T = any, R = AxiosResponse<T>>(url: string, config): Promise<R>;
}

// 2. AxiosResponse 返回值类型
export interface AxiosResponse<T = any, D = any>  {
  data: T;
  // ...省略
}

image.png

TS 类型升级支持

+ interface ApiRes<T> {
+   msg: string;
+   result: T;
+ }


-      // 能用,但 res.data 的返回值类型为 any
-      const res = await request.get("/home/category/head");
+     // 🎉恭喜已经有 TS 类型提醒了,res.data 能提示 result 和正确的类型
+      const res = await request.get<ApiRes<CategoryList>>("/home/category/head");

TS 类型进阶封装(先使用)

  • 📦 课堂中先直接使用,提高开发效率。
  • ⏰ 课后大家自行解读,先删除所有 TS 类型读代码,再添加 TS 类型,提升自己 TS 类型处理能力。

参考代码

src\utils\request.ts

- import axios from "axios";
+ import axios, { type Method } from "axios";

const instance = axios.create({
  baseURL: "http://pcapi-xiaotuxian-front-devtest.itheima.net/",
  timeout: 5000,
});

// 添加请求拦截器
instance.interceptors.request.use(
  function (config) {
    // 在发送请求之前做些什么
    return config;
  },
  function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
  }
);

// 添加响应拦截器
instance.interceptors.response.use(
  function (response) {
    return response;
  },
  function (error) {
    // 对响应错误做点什么
    return Promise.reject(error);
  }
);

+ // 后端返回的接口数据格式
+ interface ApiRes<T> {
+    msg: string;
+    result: T;
+ }

+/**
+ * axios 二次封装,整合 TS 类型
+ * @param url  请求地址
+ * @param method  请求类型
+ * @param submitData  对象类型,提交数据
+ */
+export const http = <T>(method: Method, url: string, submitData?: object) => {
+  return instance.request<ApiRes<T>>({
+    url,
+    method,
+    // 🔔 自动设置合适的 params/data 键名称,如果 method 为 get 用 params 传请求参数,否则用 data
+    [method.toUpperCase() === "GET" ? "params" : "data"]: submitData,
+  });
+};

export default instance;

使用

-import request from "@/utils/request";
+import { http } from "@/utils/request";

- const res = await request.get<ApiRes<CategoryList>>("/home/category/head");
+ const res = await http<CategoryList>("GET", "/home/category/head");

详情模块

目标:界面渲染部分我们快速准备,详情模块的重点都在组件封装。

基础布局和路由

任务目标: 完成商品详情的基础布局和路由配置

image.png

1)新建页面组件

src/views/Goods/index.vue

<script setup lang="ts">
//
</script>

<template>
  <div class="xtx-goods-page">
    <div class="container">
      <!-- 商品信息 -->
      <div class="goods-info">
        <!-- 图片预览区 -->
        <div class="media"></div>
        <!-- 商品信息区 -->
        <div class="spec"></div>
      </div>
      <!-- 商品详情 -->
      <div class="goods-footer">
        <div class="goods-article">
          <!-- 商品详情 -->
          <div class="goods-tabs"></div>
        </div>
        <!-- 24热榜+专题推荐 -->
        <div class="goods-aside"></div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="less">
.container {
  margin-top: 20px;
}
.goods-info {
  min-height: 600px;
  background: #fff;
  display: flex;
  .media {
    width: 580px;
    height: 600px;
    padding: 30px 50px;
  }
  .spec {
    flex: 1;
    padding: 30px 30px 30px 0;
  }
}
.goods-footer {
  display: flex;
  margin-top: 20px;
  .goods-article {
    width: 940px;
    margin-right: 20px;
  }
  .goods-aside {
    width: 280px;
    min-height: 1000px;
  }
}
.goods-tabs {
  min-height: 600px;
  background: #fff;
}
.goods-warn {
  min-height: 600px;
  background: #fff;
  margin-top: 20px;
}
</style>

2)路由配置

src/router/index.ts

const routes = [
  {
    path: '/',
    component: Layout,
    children: [
      // ...
      {
        path: '/goods/:id',
        component: () => import('@/views/Goods/index.vue')
      }
    ]
  }
]

3)新鲜好物添加路由跳转链接,测试路由跳转

商品详情 Store 和 类型声明

定义 Store

定义新 Store : src\store\modules\goods.ts

import { defineStore } from 'pinia';

export const useGoodsStore = defineStore('goods', () => {
  // 记得 return
  return {};
});

合并新 Store: src\store\index.ts

export * from './modules/goods';

定义类型声明

新建类型声明文件:src\types\modules\goods.d.ts

// 商品详情类型声明文件

合并类型声明:src\types\index.d.ts

// 统一导出所有类型文件
export * from "./api/goods";

获取商品详情数据

步骤

  1. 获取路由 id 参数

  2. 在组件中直接调用 axios 发送请求

  3. 准备 TS 类型声明文件

  4. 保存后端返回数据,并指定TS类型(有更好的提示)

商品详情接口

基本信息

Path: /goods

Method: GET

接口描述:

规格集合一定要和 skus 集合下的 specs 顺序保持一致

请求参数

Query

参数名称是否必须示例备注
id3995885商品id

发送请求

实现步骤

store 中封装 请求方法

// 根据id获取商品详情
const getGoodsDetail = async (id: string) => {
  const res = await http('GET', '/goods', { id: id });
  console.log('GET', '/goods', res);
};

// 记得 return

在组件 setup 中获取商品详情数据

<script setup lang="ts">
import { useGoodsStore } from '@/store';
import { useRoute } from 'vue-router';

// 获取 goods Store
const goods = useGoodsStore();
// 获取 路由信息
const route = useRoute();
// 调用获取商品详情方法
// 注意事项:
//    路由参数类型是联合类型,需通过 as 断言修正类型
goods.getGoodsDetail(route.params.id as string);
</script>

store 中指定类型

const goodsDetail = ref<GoodsDetail>();
const getGoodsDetail = async (id: string) => {
  const res = await http<GoodsDetail>("GET", "/goods", { id: id });
  console.log("/goods", res.data.result);
  goodsDetail.value = res.data.result;
};

// 记得 return

商品信息渲染

image.png

静态结构准备-CV

src\views\Goods\index.vue,替换原本的 templatestyle

<template>
  <div class="xtx-goods-page">
    <div class="container">
      <!-- 商品信息 -->
      <div class="goods-info">
        <div class="media">
          <!-- 图片预览区 -->
          <div class="goods-image">
            <!-- 图片预览组件 -->
          </div>
          <!-- 统计数量 -->
          <ul class="goods-sales">
            <li>
              <p>销量人气</p>
              <p>100+</p>
              <p><i class="iconfont icon-task-filling"></i>销量人气</p>
            </li>
            <li>
              <p>商品评价</p>
              <p>200+</p>
              <p><i class="iconfont icon-comment-filling"></i>查看评价</p>
            </li>
            <li>
              <p>收藏人气</p>
              <p>80+</p>
              <p><i class="iconfont icon-favorite-filling"></i>收藏商品</p>
            </li>
            <li>
              <p>品牌信息</p>
              <p>90+</p>
              <p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p>
            </li>
          </ul>
        </div>
        <!-- 商品信息区 -->
        <div class="spec">
          <!-- 商品主要信息 -->
          <div class="goods-main">
            <p class="g-name">这是商品标题</p>
            <p class="g-desc">这是商品描述</p>
            <p class="g-desc">这是选中的商品规格</p>
            <p class="g-price">
              <span>商品现在的价钱</span>
              <span>商品原来的价格</span>
            </p>
            <div class="g-service">
              <dl>
                <dt>促销</dt>
                <dd>12月好物放送,App领券购买直降120元</dd>
              </dl>
              <dl>
                <dt>配送</dt>
                <dd>至</dd>
                <dd>
                  <XtxCity />
                </dd>
              </dl>
              <dl>
                <dt>服务</dt>
                <dd>
                  <span>无忧退货</span>
                  <span>快速退款</span>
                  <span>免费包邮</span>
                  <a href="javascript:;">了解详情</a>
                </dd>
              </dl>
            </div>
          </div>
          <!-- 规格选择组件 -->
          <!-- 数量选择组件 -->
          <!-- 按钮组件 -->
        </div>
      </div>
      <!-- 商品详情 -->
      <div class="goods-footer">
        <div class="goods-article">
          <!-- 商品详情 -->
          <div class="goods-tabs"></div>
        </div>
        <!-- 24热榜+专题推荐 -->
        <div class="goods-aside"></div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="less">
.container {
  margin-top: 20px;
}

// 商品信息
.goods-info {
  min-height: 600px;
  background: #fff;
  display: flex;
  .media {
    width: 580px;
    height: 600px;
    padding: 30px 50px;
  }
  .spec {
    flex: 1;
    padding: 30px 30px 30px 0;
  }
}

// 图片预览区
.goods-image {
  width: 480px;
  height: 400px;
  background-color: #eee;
}

// 统计数量
.goods-sales {
  display: flex;
  width: 400px;
  align-items: center;
  text-align: center;
  height: 140px;
  li {
    flex: 1;
    position: relative;
    ~ li::after {
      position: absolute;
      top: 10px;
      left: 0;
      height: 60px;
      border-left: 1px solid #e4e4e4;
      content: '';
    }
    p {
      &:first-child {
        color: #999;
      }
      &:nth-child(2) {
        color: @priceColor;
        margin-top: 10px;
      }
      &:last-child {
        color: #666;
        margin-top: 10px;
        i {
          color: @xtxColor;
          font-size: 14px;
          margin-right: 2px;
        }
        &:hover {
          color: @xtxColor;
          cursor: pointer;
        }
      }
    }
  }
}

// 商品信息区
.spec {
  .g-name {
    font-size: 22px;
  }
  .g-desc {
    color: #999;
    margin-top: 10px;
  }
  .g-price {
    margin-top: 10px;
    span {
      &::before {
        content: '¥';
        font-size: 14px;
      }
      &:first-child {
        color: @priceColor;
        margin-right: 10px;
        font-size: 22px;
      }
      &:last-child {
        color: #999;
        text-decoration: line-through;
        font-size: 16px;
      }
    }
  }
  .g-service {
    background: #f5f5f5;
    width: 500px;
    padding: 20px 10px 0 10px;
    margin-top: 10px;
    dl {
      padding-bottom: 20px;
      display: flex;
      align-items: center;
      dt {
        width: 50px;
        color: #999;
      }
      dd {
        color: #666;
        &:last-child {
          span {
            margin-right: 10px;
            &::before {
              content: '•';
              color: @xtxColor;
              margin-right: 2px;
            }
          }
          a {
            color: @xtxColor;
          }
        }
      }
    }
  }
}

.goods-footer {
  display: flex;
  margin-top: 20px;
  .goods-article {
    width: 940px;
    margin-right: 20px;
  }
  .goods-aside {
    width: 280px;
    min-height: 1000px;
  }
}
.goods-tabs {
  min-height: 600px;
  background: #fff;
}
.goods-warn {
  min-height: 600px;
  background: #fff;
  margin-top: 20px;
}
</style>

商品信息渲染

任务目标: 按照功能渲染商品信息。

image.png

商品信息区

  <!-- 商品信息区 -->
  <div class="spec">
    <div class="goods-main">
      <p class="g-name">{{ goods.goodsDetail?.name }}</p>
      <p class="g-desc">{{ goods.goodsDetail?.desc }}</p>
      <p class="g-desc">这是选中的商品规格</p>
      <p class="g-price">
        <span>{{ goods.goodsDetail?.price }}</span>
        <span>{{ goods.goodsDetail?.oldPrice }}</span>
      </p>
    ...

storeToRefs 应用

<script setup lang="ts">
import { storeToRefs } from 'pinia';
// ...省略其他代码
// 👍解构依旧能保持响应式
const { goodsDetail } = storeToRefs(goods);
</script>

  <!-- 商品信息区 -->
  <div class="spec">
    <div class="goods-main">
      <p class="g-name">{{ goodsDetail?.name }}</p>
      <p class="g-desc">{{ goodsDetail?.desc }}</p>
      <p class="g-desc">这是选中的商品规格</p>
      <p class="g-price">
        <span>{{ goodsDetail?.price }}</span>
        <span>{{ goodsDetail?.oldPrice }}</span>
      </p>
    ...

添加 loading 效果

  • 为了使用方便,商品数据在渲染是添加 v-if 条件,使用数据时省略大量的 ?. 写法。

  • 同时增强用户体验,添加 loading 效果。

<template>
  <div class="xtx-goods-page">
    <div class="container">
      <!-- 商品信息 -->
-      <div class="goods-info">
+      <div v-if="goodsDetail" class="goods-info">
       ...
+      <div v-else class="goods-info xtx-loading"></div>
    ...
</template>

<style scoped lang="less">
// ...
+ .goods-info.xtx-loading {
+   background: #fff url('@/assets/images/loading.gif') no-repeat center;
+ }
</style>

图片预览渲染

任务目标:通过图片预览组件,实现商品图片预览功能。

image.png

<!-- 图片预览区 -->
<div class="media">
  <!-- 图片预览区 -->
  <XtxImageView :image-list="goodsDetail.mainPictures" />
</div>

按钮组件实现🚨

任务目标: 实现按钮组件的封装

组件用法

先直接使用组件库提供的组件,尝试自己实现组件的封装。

<!-- 按钮组件 -->
<XtxButton type="primary" size="middle" style="margin-top: 20px">
  加入购物车
</XtxButton>

组件开发步骤

  1. 准备静态结构
  2. 分析按钮组件的自定义属性
  3. defineProps 定义 Props 接收值
  4. 模板中使用父组件传过来的值设置按钮样式

静态结构-CV

新建组件:src\views\Test\components\MyButton.vue

<script setup lang="ts">
// 步骤:
// 1. 准备静态结构
// 2. 分析按钮组件的自定义属性
// 3. `defineProps` 定义 Props 接收值
// 4. 模板中使用父组件传过来的值设置按钮样式
</script>

<template>
  <button class="xtx-button ellipsis" :class="`middle default`">
    <slot></slot>
  </button>
</template>

<style scoped lang="less">
// 基于类名定义一些和定制样式无关的样式
.xtx-button {
  appearance: none;
  border: none;
  outline: none;
  background: #fff;
  text-align: center;
  border: 1px solid transparent;
  border-radius: 4px;
  cursor: pointer;
}
// ---------大小类名-------------
// 大
.large {
  width: 240px;
  height: 50px;
  font-size: 16px;
}
// 中
.middle {
  width: 180px;
  height: 50px;
  font-size: 16px;
}
// 小
.small {
  width: 100px;
  height: 32px;
}
//超小
.mini {
  width: 60px;
  height: 32px;
}

// ---------颜色类名----------
// 默认色
.default {
  border-color: #e4e4e4;
  color: #666;
}
// 确认
.primary {
  border-color: @xtxColor;
  background-color: @xtxColor;
  color: #fff;
}
// 普通
.plain {
  border-color: @xtxColor;
  color: @xtxColor;
  background-color: lighten(@xtxColor, 50%);
}
// 灰色
.gray {
  border-color: #ccc;
  background-color: #ccc;
  color: #fff;
}
</style>

Props 默认值

写法1 - 响应性语法糖(实验性最新写法)

  • 实验性写法 Vue 版本要求 vue@3.2.25+,需修改 Vite 的配置,并且修改 EsLint 检查规则后才能使用。
  • 实验性写法很方便,属于响应性语法糖,但官方还没正式发布。
<script setup lang="ts">
// 响应性语法糖(实验性)写法 `Vue` 版本要求 `vue@3.2.25+`
// 🚨 需修改配置后才能使用:
//      1. vite.config.ts   开启响应性语法糖(实验性)
//      2. .eslintrc.cjs    关闭检查规则
// 注意:实验性写法在 yarn dev 时会有黄色提醒,我们知道自己再干什么,忽略黄色提醒即可
const { type = 'default', size = 'middle' } = defineProps<{
  type?: 'default' | 'primary' | 'plain' | 'gray';
  size?: 'large' | 'middle' | 'small' | 'mini';
}>();
</script>

vite.config.ts

import { fileURLToPath, URL } from 'url';

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue({
+      // 开启<实验性>的响应性语法糖来解决 prop 定义默认值的问题
+      reactivityTransform: true,
    }),
    vueJsx(),
  ],
});

关闭 eslint 规则

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution');

module.exports = {
  root: true,
  // 自定义规则
  rules: {
    // 关闭多词组合的组件命名要求: off 关闭
    'vue/multi-word-component-names': 'off',
+    // 关闭 props 不能解构的规则: off 关闭
+    'vue/no-setup-props-destructure': 'off',
  },
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/eslint-config-typescript/recommended',
    '@vue/eslint-config-prettier',
  ],
};

写法2 - 类型断言写法(早期写法)

  • PropType 类型断言写法同样适用于 选项式API组合式API
<script setup lang="ts">
// 设置 Props 默认值
// PropType 类型定义写法同样适用于选项式 API
import type { PropType } from "vue";

defineProps({
  size: {
    type: String as PropType<"mini" | "small" | "middle" | "large">,
    default: "small",
  },
  type: {
    type: String as PropType<"gray" | "plain" | "primary" | "default">,
    default: "gray",
  },
});
</script>

常见问题:

  • 问:开发者如何选择设置默认值的写法?
  • 答:
    • 实验性写法最方便,新项目推荐,但要求 Vue 版本较新,目前还没完全定稿,所以终端有提醒。
    • 正式项目建议按团队习惯来选择,老项目用类型断言写法较为常见。

数量组件实现🚨🚨

任务目标: 实现商品的数量操作组件的封装

image.png

组件用法

先直接使用组件库提供的组件,尝试自己实现组件的封装。

const count = ref(1);

<!-- 数量选择组件 -->
<XtxCount
  is-label
  v-model="count"
  :min="1"
  :max="goodsDetail.inventory"
/>

v-model 语法糖

目标:熟悉 v-model 双向绑定语法糖原理,并实现自定义组件双向绑定。

父组件 v-model 使用

<!-- v-model 语法糖由 :modelValue 和 @update:modelValue 两部分组合而成 -->
<MyCount  v-model="count" />

<!-- 语法糖原理 -->
<MyCount :model-value="count" @update:model-value="(val) => {count = val}" />

注意事项:

  • Vue2中,组件 v-model 双向绑定语法糖由 :value@input 两部分组合而成。
  • Vue3中,组件 v-model 双向绑定语法糖由 :modelValue@update:modelValue 两部分组合而成。
  • 原生表单标签 input、 select 、 textarea 使用不受影响,只影响自定义组件封装。
  • vue3文档:cn.vuejs.org/guide/compo…

子组件 v-model 封装

  • 子组件处理 v-model 需要定义 modelValue 属性和 update:modelValue 事件。
<script setup lang="ts">
// 接收 v-model 的自定义属性 modelValue
const { modelValue } = defineProps<{
  modelValue: number;
}>();

// 注册 v-model 的自定义事件 update:modelValue
const emit = defineEmits<{
  (event: 'update:modelValue', val: number): void;
}>();
</script>

组件开发步骤

  1. 搭建组件静态结构并实现增减逻辑
  2. 通过 props 传入控制最大和最小值
  3. 实现组件的 v-model 双向绑定 (难点)
<MyCount v-model="count"><MyCount>  数字框

希望实现 v-model 双向绑定
1. 父组件中,修改了,设置了 num 值, 子组件需要实时更新
2. 子组件中,点击了 +  -, 调整了数值, 希望绑定的 num 值也能自动更新

静态结构-CV

新建组件:src\views\Test\components\MyCount.vue

<script setup lang="ts">
// 需求:
//   1. 显示标签和数量
//   2. 加号和最大值处理
//   3. 减号和最小值处理
//   4. props 默认值处理
</script>

<template>
  <div class="xtx-numbox">
    <div class="label">数量</div>
    <div class="numbox">
      <a href="javascript:;">-</a>
      <input type="text" readonly :value="1" />
      <a href="javascript:;">+</a>
    </div>
  </div>
</template>

<style scoped lang="less">
.xtx-numbox {
  display: flex;
  align-items: center;
  .label {
    width: 60px;
    color: #999;
    padding-left: 10px;
  }
  .numbox {
    width: 120px;
    height: 30px;
    border: 1px solid #e4e4e4;
    display: flex;
    > a {
      width: 29px;
      line-height: 28px;
      text-align: center;
      background: #f8f8f8;
      font-size: 16px;
      color: #666;
      &:first-of-type {
        border-right: 1px solid #e4e4e4;
      }
      &:last-of-type {
        border-left: 1px solid #e4e4e4;
      }
    }
    > input {
      width: 60px;
      padding: 0 5px;
      text-align: center;
      color: #666;
    }
  }
}
</style>

显示标签和数量

<script setup lang="ts">
defineProps<{
  // v-model 语法糖的 props
  modelValue: number;
  // 是否显示标签
  isLabel?: boolean;
}>();
</script>

<template>
  <div class="xtx-numbox">
    <div class="label" v-if="isLabel">数量</div>
    <div class="numbox">
      <a href="javascript:;">-</a>
      <input type="text" readonly :value="modelValue" />
      <a href="javascript:;">+</a>
    </div>
  </div>
</template>

测试

<script setup lang="ts">
import { ref } from 'vue';
import MyCount from './components/MyCount.vue';
const count = ref(1);
</script>

<template>
  <MyCount v-model="count" />
</template>

加号和最大值处理

<script setup lang="ts">
const {
  modelValue,
  isLabel = false,
  max = 100,
} = defineProps<{
  // v-model 语法糖的 props
  modelValue: number;
  // 是否显示标签
  isLabel?: boolean;
  // 最大值
  max?: number;
}>();

// v-model 语法糖的 update:modelValue
const emit = defineEmits<{
  (event: 'update:modelValue', value: number): void;
}>();

// 加号和最大值处理
const add = () => {
  // 临时加
  const temp = modelValue + 1;
  // 是否超过最大值,超过直接 return 退出,不更新父组件的数据
  if (temp > max) return;
  // 更新 v-model 绑定的数据
  emit('update:modelValue', temp);
};
</script>

<template>
  <div class="xtx-numbox">
    <div v-if="isLabel" class="label">数量</div>
    <div class="numbox">
      <a href="javascript:;">-</a>
      <input type="text" readonly :value="modelValue" />
      <a href="javascript:;" @click="add">+</a>
    </div>
  </div>
</template>

减号和最小值处理

  • 参考加号和最大值处理,点击减号按钮触发减少,最小值默认值为 1。

常见问题

  • 问:为什么我组件数量只能加到 2 就加不动了?
  • 答:解构 props 需在 vite.config.ts 中开启响应性语法糖,同时修改 .eslintrc.cjs 规则。

商品规格组件使用

目标:商品规格处理是电商网站一个非常核心的功能模块,选择不同规格会变化价格和库存最大值。

测试商品id: 1379052170040578049 和 1369155859933827074

image.png

理解基础 sku 概念

官方话术:

  • SPU(Standard Product Unit):标准化产品单元,展示用商品 id 。

    Iphone12 => SPU )

  • SKU(Stock Keeping Unit):库存量单位,下单用 sku id。

    Iphone12 蓝色256g => SKU )

图解:

image.png

基础用法

<script setup lang="ts">
+import type { SkuEmit } from '@/components/XtxUI/Sku/index.vue';


+const changeSku = (val: SkuEmit) => {
+  console.log('当前选择的sku信息为', val);
+};
</script>


<template>
        ...
        <!-- 商品信息区 -->
        <div class="spec">
          <!-- 商品主要信息 -->
          <div class="goods-main">
              ...
          </div>
+          <!-- 📌规格选择组件 -->
+          <XtxSku :goods="goodsDetail" @change="changeSku" />
          <!-- 数量选择组件 -->
          <!-- 按钮组件 -->
        </div>
        ...
</template>

业务整合

<script setup lang="ts">
    
// 获取 XtxSku 组件选中的商品信息
const skuId = ref('');  // 🔔存储 skuId 用于加入购物车
const specsText = ref('');  // 🔔商品规格说明
const changeSku = (val: SkuEmit) => {
  // 商品规格 和 skuId 更新
  skuId.value = val.skuId || '';
  specsText.value = val.specsText || '';
  // 类型守卫,排除掉 undefined 情况
  if (goodsDetail.value && val.skuId) {
    // 根据选中规格,更新商品库存,最新价格,原始价格
    goodsDetail.value.inventory = val.inventory;
    goodsDetail.value.price = val.price;
    goodsDetail.value.oldPrice = val.oldPrice;
  }
};

</script>

<template>
  <!-- 商品描述 -->
  <p class="g-desc">规格:{{ specsText }}</p>
</template>

Store 数据缓存问题

store 数据组件卸载不会重置,最终导致商品详情页 v-if="goodsDetail" 工作异常。

src\store\modules\goods.ts

export const useGoodsStore = defineStore('goods', () => {
  // 根据id获取商品详情
  const goodsDetail = ref<GoodsDetail>();
  const getGoodsDetail = async (id: string) => {
+    // 🐛修复BUG,先清空数据,再请求新数据,否则页面中的 v-if="goodsDetail" 工作异常
+    goodsDetail.value = undefined;
    const res = await http<GoodsDetail>('GET', '/goods', { id: id });
    goodsDetail.value = res.data.result;
  };

  // 记得 return
  return {
    goodsDetail,
    getGoodsDetail,
  };
});

详情展示实现-课后练习

说明:实现商品详情信息的展示,自己课后实现即可。

实现步骤

  1. 把详情数据通过 props 传入
  2. 接收数据并渲染模板

代码落地

1)传入数据

<!-- 商品详情 -->
<GoodsDetail :goods="goods"/>

2)接收并渲染数据

<script setup lang="ts">
import type { Goods } from "@/types";

interface Props {
  goods: Goods;
}
defineProps<Props>();
</script>

<template>
  <div class="goods-tabs">
    <nav>
      <a>商品详情</a>
    </nav>
    <div class="goods-detail">
      <!-- 属性 -->
      <ul class="attrs">
        <li v-for="item in goods.details.properties" :key="item.value">
          <span class="dt">{{ item.name }}</span>
          <span class="dd">{{ item.value }}</span>
        </li>
      </ul>
      <!-- 图片 -->
      <img
        v-for="item in goods.details.pictures"
        :key="item"
        :src="item"
        alt=""
      />
    </div>
  </div>
</template>

<style scoped lang="less">
.goods-tabs {
  min-height: 600px;
  background: #fff;
  nav {
    height: 70px;
    line-height: 70px;
    display: flex;
    border-bottom: 1px solid #f5f5f5;
    a {
      padding: 0 40px;
      font-size: 18px;
      position: relative;
      > span {
        color: @priceColor;
        font-size: 16px;
        margin-left: 10px;
      }
    }
  }
}
.goods-detail {
  padding: 40px;
  .attrs {
    display: flex;
    flex-wrap: wrap;
    margin-bottom: 30px;
    li {
      display: flex;
      margin-bottom: 10px;
      width: 50%;
      .dt {
        width: 100px;
        color: #999;
      }
      .dd {
        flex: 1;
        color: #666;
      }
    }
  }
  > img {
    width: 100%;
  }
}
</style>

热榜区域实现-课后练习

说明:列表渲染部分,自己课后实现即可。

任务目标: 展示24小时热榜商品,和周热榜商品

实现步骤

  1. 定义一个组件,完成多个组件展现型态,根据传入组件的类型决定
    • 1: 代表24小时热销榜
    • 2: 代表周热销榜
    • 3: 代表总热销榜
  2. 根据传入的type获取数据,完成商品展示和标题样式的设置

1. 定义组件

src/views/Goods/components/goods-hot.vue

<script setup lang="ts">
import GoodsItem from "@/views/Category/components/goods-item.vue";
import type { PropType } from "vue";
const { type = 1 } = defineProps<{
  type?: number
}>();

// 标题对象
const titleObj = {
  1: "24小时热销榜",
  2: "周热销榜",
  3: "总热销榜",
};
</script>

<template>
  <div class="goods-hot">
    <h3>{{ titleObj[props.type] }}</h3>
    <div class="goods-list">
      <!-- 商品区块 -->
      <GoodsItem v-for="(item, index) in 4" :key="index" />
    </div>
  </div>
</template>

<style scoped lang="less">
.goods-hot {
  background-color: #fff;
  margin-bottom: 20px;
  h3 {
    height: 70px;
    background: @helpColor;
    color: #fff;
    font-size: 18px;
    line-height: 70px;
    padding-left: 25px;
    margin-bottom: 10px;
    font-weight: normal;
  }
  .goods-list {
    display: flex;
    flex-direction: column;
    align-items: center;
  }
}
</style>

2)使用组件

src/views/Goods/index.vue

import GoodsHot from './components/goods-hot.vue'


<!-- 24热榜+专题推荐 -->
<div class="goods-aside">
  <GoodsHot :type="1" />
  <GoodsHot :type="2" />
  <GoodsHot :type="3" />
</div>

2. 获取数据渲染组件

src/views/goods/components/goot-hot.vue

<script setup lang="ts">
// 🚨导入时出现命名冲突,可以通过 as 重命名
import type { GoodsItem as Item } from "@/types";
import { http } from "@/utils/request";
import GoodsItem from "@/views/Category/components/goods-item.vue";
import { onMounted, ref, type PropType } from "vue";
import { useRoute } from "vue-router";
const props = defineProps({
  type: {
    type: Number as PropType<1 | 2 | 3>,
    default: 1,
  },
});

// 标题对象
const titleObj = {
  1: "24小时热销榜",
  2: "周热销榜",
  3: "总热销榜",
};

// 发送请求获取数据
const route = useRoute();
const { id } = route.params;
const list = ref<Item[]>([]);
onMounted(async () => {
  const res = await http<Item[]>("GET", "/goods/hot", {
    id: id,
    type: props.type,
  });
  list.value = res.data.result;
});
</script>

<template>
  <div class="goods-hot">
    <h3>{{ titleObj[props.type] }}</h3>
    <div class="goods-list">
      <!-- 商品区块 -->
      <GoodsItem v-for="item in list" :key="item.id" :goods="item" />
    </div>
  </div>
</template>

<style scoped lang="less">
.goods-hot {
  background-color: #fff;
  margin-bottom: 20px;
  h3 {
    height: 70px;
    background: @helpColor;
    color: #fff;
    font-size: 18px;
    line-height: 70px;
    padding-left: 25px;
    margin-bottom: 10px;
    font-weight: normal;
  }
  .goods-list {
    display: flex;
    flex-direction: column;
    align-items: center;
  }
}
</style>