【保姆级实战教程】Vue3+TS+Vite开发H5移动端应用--开发流程

594 阅读4分钟

本文主要介绍

1、Vue3的Composition-API相关用法
2、如何在Vue3使用Swiper
3、json-server:本地模拟接口快速开发
4、全局状态数据的处理
5、一些常见坑的解决方案
.......

本教程主要用作 Vue3+TS+vite 项目开发规范和流程的展示,由于项目代码量不小,这里仅通过第一个楼层开发过程来进行讲解。

新周三落地页.png

页面结构分为:顶部banner楼层区域

顶部banner

顶部banner主要有:左侧浮窗背景图右侧按钮区域
左侧浮窗图片跳转链接背景图均由后端配置,右侧按钮分别是活动说明分享的按钮

// src/views/Home.vue
<template>
  <!-- 页面中所有内容 -->
  <section class="page_container">
    <!-- 头部 banner -->
    <header>
      <div class="header_btn header_btn--desc" @click="hClickNav('desc')">
        <img class="header_Nav--img" src="../assets/desc.png" alt="" />
      </div>
      <div class="header_btn header_btn--share" @click="hClickNav('share')">
        <img class="header_Nav--img" src="../assets/share.png" alt="" />
      </div>

      <template v-for="e in floorData">
        <!-- banner图 -->
        <img
          :key="e.MODULE_NAME"
          v-if="e.MODULE_NAME === 'banner'"
          class="header_banner--img"
          :src="e.MODULE_IMAGE"
          alt=""
        />
        <!-- 浮窗 -->
        <div
          v-if="e.MODULE_NAME === 'flutter'"
          class="header_btn header_btn--eManager"
          :key="e.MODULE_NAME"
          @click="jumpLink(e.MODULE_LINK)"
        >
          <img class="eManag_btn--img" :src="e.MODULE_IMAGE" alt="" />
        </div>
      </template>
    </header>
    <!-- 页面主体 -->
    <main>
      <!-- 所有楼层组件 -->
      <component
        :is="floorType(e.MODULE_NAME)"
        :floorData="e"
        v-for="e in floorData"
        :key="e.MODULE_NAME"
      />
    </main>
    <!-- 页面底部 -->
    <footer></footer>
  </section>
  <!-- 弹窗遮罩 -->
  <div class="popup_mask" v-show="popType"></div>
  <!-- 弹窗部分 -->
  <div class="popup popup_suc" v-if="popType === 'suc'">
    <div class="popup_title">这是成功弹窗</div>
  </div>
  <div class="popup popup_fail" v-if="popType === 'fail'">
    <div class="popup_title">这是失败弹窗</div>
  </div>
</template>

<script lang="ts">
import {
  defineComponent,
  getCurrentInstance,
  onMounted,
  reactive,
  toRefs,
} from "vue";
import VIP from "@/components/floor/VIP.vue";
// 根据楼层ID渲染楼层组件
const floorType = (floorID: string) => {
  let floorName = "";
  switch (floorID) {
    case "vip":
      // 会员楼层
      floorName = "VIP";
      break;
   // 其他楼层...   
    default:
      break;
  }
  return floorName;
};
export default defineComponent({
  components: { VIP },
  setup() {
    // 响应式数据
    const state = reactive<any>({
      pageData: {},
      floorData: [],
    });
    const _this = getCurrentInstance()!;
    // 获取全局方法
    const { jumpLink, setState, $API, toast  } = _this.appContext.config.globalProperties;
    // 点击banner导航
    const hClickNav = (type: string) => {
      switch (type) {
        case "desc":
          // 点击查看详情按钮
          const bannerFloor = state.floorData.find(
            (e: any) => e.MODULE_NAME === "banner"
          );
          if (bannerFloor) jumpLink(bannerFloor.MODULE_LINK);
          break;
        case "share":
          // 点击分享按钮
          alert("调用平台分享方法");
          break;
        default:
          break;
      }
    };
    onMounted(() => {
      $API.homeInit().then((res: any) => {
          if (res.code === 1000) {
            const { showModules, ...pageData } = res.data;
            // 获取页面数据、楼层数据
            state.pageData = pageData;
            state.floorData = showModules;
          }
        });
    });
    // 使用toRefs处理reactive数据后,在解包reactive数据不会丢失响应式
    return { ...toRefs(state), floorType, hClickNav };
  },
});
</script>

<style scoped lang="scss">
section {
  background-color: #fc6657;
}
main {
  margin-top: -332px;
}
.popup_mask {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background-color: rgba(0, 0, 0, 0.7);
}
.popup {
  position: absolute;
  top: 50%;
  left: 50%;
  right: 50%;
  bottom: 50%;
  transform: translate(-50%, -50%);
  .popup_title {
    color: red;
  }
}
header {
  position: relative;
  .header_banner--img {
    width: 750px;
  }
  .header_btn--desc {
    position: absolute;
    top: 50px;
    right: 50px;
    width: 110px;
    height: 50px;
    line-height: 50px;
    text-align: center;
    font-size: 20px;
    border-radius: 25px;
    color: #fff;
  }
  .header_btn {
    width: 60px;
  }
  .header_btn--desc {
    position: absolute;
    top: 222px;
    right: 0;
  }
  .header_btn--share {
    position: absolute;
    top: 302px;
    right: 0;
  }
  .header_Nav--img {
    width: 60px;
  }
  .header_btn--eManager {
    position: absolute;
    width: 90px;
    left: 0;
    right: auto;
    top: 300px;
  }
  .eManag_btn--img {
    width: 90px;
  }
}
</style>

VIP楼层部分

1631581620672.gif

业务需求

1、VIP楼层用于会员身份展示,尊享会员生活服务 两个会员板块要做成 轮播图 形式不可循环。
2、会员开通后滑块左上角显示 已开通 角标,两个会员哪个开通页面初始就展示哪一个,都开通或未开通展示第一个。页面初始10秒内 ,用户没有手动切换展示下一个会员板块,就展示另一个会员板块。
3、会员未开通后滑块右上角显示 解锁权益 ,会员开通后显示 解锁更多 。如果所有会员都开通了,隐藏右上角的按钮
4、点击会员滑块的 解锁权益 按钮,拉起开通会员的弹窗,会员开通成功后 解锁权益 按钮变为 解锁更多 或者 隐藏掉
5、没有会员滑块下方的 卡券展示 做成循环轮播效果
6、卡券有多个展示状态:解锁权益立即领取去使用去看看

  • 解锁权益:卡券所属的会员未开通,卡券状态为解锁权益,点击卡券拉起办理会员弹窗
  • 立即领取:点击卡券后,调取卡券领取接口,领取成功卡券状态变为去使用,领取失败给予弱提示
  • 去使用:点击卡券跳转卡券领取页面
  • 去看看:页面滚动到限量抢楼层

开发设计

将所有楼层抽离到src/component/floor中、会员滑块的卡券轮播图也单独抽离出来(src/component)方便复用跟维护

安装配置Swiper

安装Swiper6.x版本:
npm install swiper@6.x

VIP楼层

Swiper6.X版本,swiper只具备核心功能,例如其他自动播放、指示器模块都需要单独安装

//  src/component/floor/VIP.vue
<template>
  <div class="container">
    <Swiper
      :initialSlide="swiperOptions.initialSlide"
      :autoplay="swiperOptions.autoplay"
      :observer="swiperOptions.observer"
      :observeParents="swiperOptions.observeParents"
      :observeSlideChildren="swiperOptions.observeSlideChildren"
      v-if="floorData"
      @swiper="setSwiper"
      @slideChange="slideChange"
    >
      <template v-for="e in floorData.vipLists">
        <!-- 尊享会员 -->
        <SwiperSlide
          v-if="e.SPECIAL_FLAG === 'ZXVIP'"
          class="zxMember_slide"
          :key="e.SPECIAL_FLAG"
        >
          <div class="banner_slide--content">
            <div class="banner_slide--wrap">
              <!-- 未开通会员 -->
              <img
                src="../../assets/opened.png"
                alt=""
                class="opened_flag--img"
                v-if="e.OPEN_FLAG === '1'"
              />
              <!-- 已开通会员 -->
              <span class="banner_slide--text" v-if="e.OPEN_FLAG === '1'"
                >点击领取您的专属权益</span
              >
              <span class="banner_slide--text" v-else>300+ 权益优惠购</span>
              <img :src="e.MODULE_IMAGE" alt="" class="banner_slide_img" />
              <div class="banner_slide--btn">
                <img
                  src="../../assets/zxBtnOpen.png"
                  alt=""
                  class="banner_slide--img"
                  v-if="
                    e.OPEN_FLAG === '1' &&
                    floorData.vipLists.some((e) => e.OPEN_FLAG !== '1')
                  "
                  @click="toggleSlide()"
                />
                <img
                  src="../../assets/zxBtnNotOpen.png"
                  alt=""
                  class="banner_slide--img"
                  v-if="e.OPEN_FLAG !== '1'"
                  @click="setState({ popType: 'handleZXMember' })"
                />
              </div>
              <div class="banner_coupon--container">
                <!-- <CouponSwiper
                  :couponList="e.couponList"
                  :isMember="e.OPEN_FLAG === '1'"
                  :memberType="'ZXVIP'"
                /> -->
              </div>
            </div>
          </div>
        </SwiperSlide>

        <!-- 生活服务会员 -->
        <SwiperSlide
          class="shMember_slide"
          v-if="e.SPECIAL_FLAG === 'LIFEVIP'"
          :key="e.SPECIAL_FLAG"
        >
          <div class="banner_slide--content">
            <div class="banner_slide--wrap">
              <img
                v-if="e.OPEN_FLAG === '1'"
                src="../../assets/opened.png"
                alt=""
                class="opened_flag--img"
              />
              <span class="banner_slide--text" v-if="e.OPEN_FLAG === '1'"
                >点击领取您的专属权益</span
              >
              <span class="banner_slide--text" v-else>300+ 权益优惠购</span>
              <img :src="e.MODULE_IMAGE" alt="" class="banner_slide_img" />
              <div class="banner_slide--btn">
                <img
                  src="../../assets/zxBtnOpen.png"
                  alt=""
                  class="banner_slide--img"
                  v-if="
                    e.OPEN_FLAG === '1' &&
                    floorData.vipLists.some((e) => e.OPEN_FLAG !== '1')
                  "
                  @click="toggleSlide()"
                />
                <img
                  src="../../assets/zxBtnNotOpen.png"
                  alt=""
                  class="banner_slide--img"
                  v-if="e.OPEN_FLAG !== '1'"
                  @click="setState({ popType: 'handleSHMember' })"
                />
              </div>
              <div class="banner_coupon--container">
                <!-- <CouponSwiper
                  :couponList="e.couponList"
                  :isMember="e.OPEN_FLAG === '1'"
                  :memberType="'SHVIP'"
                /> -->
              </div>
            </div>
          </div>
        </SwiperSlide>
      </template>
    </Swiper>
  </div>
</template>

<script setup lang="ts">
import { getCurrentInstance, reactive, Ref, ref, toRefs } from "vue";
// 导入swiper组件
import { Swiper, SwiperSlide } from "swiper/vue";
// swiper类型
import SwiperCore, { Autoplay, Swiper as SwiperInstance } from "swiper";
// 使用组件
SwiperCore.use([Autoplay]);
const props = defineProps<{ floorData: any }>();
// 将props变为ref数据 这样解构赋值不会丢失响应式
const { floorData } = toRefs(props);
// 绑定swiper实例
let mySwiper: Ref<SwiperInstance> = ref(null!);
const setSwiper = (swiper: SwiperInstance) => (mySwiper.value = swiper);
// swiper配置模块
const modules = [Autoplay];
// 获取组件实例
const _this = getCurrentInstance();
// swiper展示的slider发生变化
const slideChange = (a: SwiperInstance) => {
  // 10s轮播/手动轮播,如果自动轮播开启,关闭自动轮播
  if (mySwiper.value.autoplay.running) mySwiper.value.autoplay.stop();
};
// 切换展示会员轮播图
const toggleSlide = () => {
  const NextSlideIndex = mySwiper.value.realIndex === 0 ? 1 : 0;
  mySwiper.value.slideTo(NextSlideIndex);
};
// Swiper配置对象
const swiperOptions = reactive({
  // swiper初始化展示slider
  initialSlide: 0,
  // 监听swiper发生变化重新初始化
  observer: true,
  observeParents: true,
  observeSlideChildren: true,
  // 拖动不会切换slider的类名
  noSwipingClass: "couponSwiper",
  // 自动轮播
  autoplay: {
    // 10s轮播
    delay: 10000,
  },
});
// 已开通的下标
const index: number = floorData.value.vipLists.findIndex(
  (e) => e.OPEN_FLAG === "1"
);
// 初始展示slider
if (index !== -1) swiperOptions.initialSlide = index;
</script>

<style scoped lang="scss">
.container {
  padding-bottom: 20px;
}
.banner-pagination {
  left: 50% !important;
  bottom: 30px;
  transform: translateX(-50%);
  :deep(.swiper-pagination-bullet) {
    width: 12px;
    height: 12px;
    background-color: #ffffff;
    opacity: 0.8;
  }
  :deep(.swiper-pagination-bullet-active) {
    background-color: #fc6657;
  }
}
.banner_slide--content {
  position: relative;
}
.banner_slide--wrap {
  position: relative;
  width: 722px;
  margin-left: 5px;
  .opened_flag--img {
    position: absolute;
    top: 19px;
    left: 200px;
    width: 80px;
  }
  .banner_slide--text {
    position: absolute;
    top: 60px;
    left: 41px;
    font-family: FZLTHJW--GB1-0;
    font-size: 20px;
    color: #8c3c10;
  }
  .banner_slide--btn {
    position: absolute;
    top: 23px;
    right: 20px;
    width: 146px;
  }
  .banner_slide--img {
    width: 722px;
  }
}

.shMember_slide {
  .opened_flag--img {
    left: 280px;
  }
  .banner_slide--text {
    color: #f00055;
  }
  :deep(.swiper-pagination-bullet-active) {
    background-color: #f00055;
  }
}
.banner_coupon--container {
  position: absolute;
  top: 100px;
  right: 23px;
  width: 660px;
  height: 220px;
  overflow: hidden;
}
</style>

// src/views/Home.vue
// 引入VIP楼层组件
import VIP from "@/components/floor/VIP.vue";

export default defineComponent({
  components: { VIP },
  ......
})

VIP楼层的卡券轮播模块

VIP卡券列表以轮播图形式展示,单独封装成组件方便复用

// src/components/CouponSwiper.vue  
<template>
  <Swiper
    :loop="swiperOptions.loop"
    :observer="swiperOptions.swiperOptions"
    :observeParents="swiperOptions.observeParents"
    :observeSlideChildren="swiperOptions.observeSlideChildren"
    :slidesPerView="swiperOptions.slidesPerView"
    :pagination="swiperOptions.pagination"
    class="couponSwiper"
    @click="hClickSwiperContainer"
  >
    <SwiperSlide v-for="(e, i) in couponList" :key="i">
      <div class="slide_coupon--item" :data-index="i">
        <div class="slide_coupon--wrap">
          <img :src="e.IMAGE" alt="" class="slide_coupon--img" />
          <div v-if="!isMember" class="slide_coupon--text">解锁权益</div>
          <div v-else-if="e.GET_FLAG === 'toGet'" class="slide_coupon--text">
            立即领取
          </div>
          <div v-else-if="e.GET_FLAG === 'got'" class="slide_coupon--text">
            已使用
          </div>
          <div v-else-if="e.GET_FLAG === 'toJump'" class="slide_coupon--text">
            去使用
          </div>
          <div
            v-else-if="e.GET_FLAG === 'acceptting'"
            class="slide_coupon--text"
          >
            受理中
          </div>
          <div v-else-if="e.GET_FLAG === 'toLook'" class="slide_coupon--text">
            去看看
          </div>
        </div>
      </div>
    </SwiperSlide>
    <div class="swiper-pagination pagination_wrap" slot="pagination"></div>
  </Swiper>
</template>

// 使用setup语法糖,声明的变量会自动暴露到模板中,导入的子组件会自动注册
<script setup lang="ts">
import { Swiper, SwiperSlide } from "swiper/vue";
import SwiperCore, { Pagination, Swiper as SwiperInstance } from "swiper";
import { getCurrentInstance, inject, reactive, Ref, ref } from "vue";
// 引入分页器模块样式
import "swiper/components/pagination/pagination.scss";
/**couponList: 卡券列表
 * memberType:会员类型
 * isMember:是否开通会员
 */
const props = defineProps(["couponList", "isMember", "memberType"]);
// 跨多层级组件通讯使用注入,scrollToLimitGet:滚动到限量抢楼层
const scrollToLimitGet = inject<Function>("scrollToLimitGet");
// swiper挂载分页器模块
SwiperCore.use([Pagination]);
// swiper配置项
const swiperOptions = reactive({
  // 监测发生变化是否重新初始化swiper
  observer: true,
  observeParents: true,
  observeSlideChildren: true,
  // 每个slider展示几个元素
  slidesPerView: 2.5,
  // 是否循环展示
  loop: true,
  pagination: {
    el: ".swiper-pagination",
  },
});
// 由于部分机型Vivo swiper的事件失效 只能用事件委托来做点击卡券处理逻辑
// 点击swiper容器
const hClickSwiperContainer = () => {
  const node = getTargetNode(event?.target as HTMLElement);
  // 未找到目标节点
  if (!node) return;
  // 获取目标节点 data-index 属性
  const {
    dataset: { index },
  } = node;
  // 获取点击卡券的数据
  const couponData = props.couponList[index!];
  hClickCoupon(couponData);
};
const getTargetNode = (
  node: HTMLElement | undefined | null
): HTMLElement | null => {
  // 递归BODY,仍未找到目标节点
  if (!node || node.nodeName.toUpperCase() === "BODY") return null;
  // 找到目标节点
  if (node.className === "slide_coupon--item") return node;
  // 未找到目标节点继续向上层找
  return getTargetNode(node.parentNode as HTMLElement);
};
// Vue全局属性、方法
const globalProperties =
  getCurrentInstance()?.appContext.config.globalProperties;
const hClickCoupon = (coupon: any) => {
  // 未开通会员 拉起办理会员弹窗
  if (!props.isMember)
    return globalProperties?.setState({
      popType:
        props.memberType === "ZXVIP" ? "handleZXMember" : "handleSHMember",
    });
  switch (coupon.GET_FLAG) {
    // 卡券为立即领取状态
    case "toGet":
      getCoupon(coupon);
      break;
    // 卡券为去使用状态
    case "toJump":
      globalProperties?.jumpLink(coupon.LINK);
      break;
    // 卡券为去看看状态
    case "toLook":
      (scrollToLimitGet as Function)();
      break;
    default:
      break;
  }
};
// 调取领取卡券接口
const getCoupon = (coupon: any) => {
  globalProperties?.$API
    .getPrize({ getFlag: "VIP", prizeNo: coupon.PRIZE_NO })
    .then((res: response) => {
      if (res.code === 1000) {
        // 领取成功,卡券变为去使用
        coupon.GET_FLAG = "toJump";
      } else if (res.code === 1320) {
        // 领取成功,卡券变为受理中
        coupon.GET_FLAG = "acceptting";
      } else {
        // 领取事失败 全局弱提示方法
        globalProperties.toast("当前活动太火爆啦,请稍后再试~");
      }
    });
};
</script>

<style scoped lang="scss">
.swiper-container {
  overflow: visible;
}
.slide_coupon--item {
  display: flex;
  justify-content: center;
  width: 270px;
  height: 180px;
  // overflow: hidden;
  .slide_coupon--wrap {
    position: relative;
    margin-top: 27px;
    width: 254px;
  }
  .slide_coupon--img {
    width: 254px;
  }
  .slide_coupon--text {
    position: absolute;
    top: 0;
    right: 6px;
    height: 120px;
    width: 70px;
    display: flex;
    align-items: center;
    justify-content: center;
    writing-mode: vertical-rl;
    writing-mode: tb-rl;
    font-family: FZLTHJW--GB1-0;
    font-size: 22px;
    color: #e70842;
  }
}

:deep(.pagination_wrap) {
  bottom: -33px !important;
  left: 50% !important;
  transform: translateX(-50%);
  z-index: 100;
  width: auto !important;
  .swiper-pagination-bullet {
    width: 12px;
    height: 12px;
    background-color: #ffffff;
    opacity: 0.9;
  }
  .swiper-pagination-bullet-active {
    background-color: #8a4621;
  }
}
</style>

// VIP楼层组件挂载CouponSwiper(src/components/floor/VIP.vue)  
 <template>
 
  <SwiperSlide
    v-if="e.SPECIAL_FLAG === 'ZXVIP'"
    class="zxMember_slide"
    :key="e.SPECIAL_FLAG"
  >
        <div class="banner_slide--content">
          <div class="banner_slide--wrap">
            // 挂载CouponSwiper组件
            <div class="banner_coupon--container">
              <CouponSwiper
                :couponList="e.couponList"
                :isMember="e.OPEN_FLAG === '1'"
                :memberType="'ZXVIP'"
              />
            </div>
          </div>
        </div>
  </SwiperSlide>
  
  <SwiperSlide
    v-if="e.SPECIAL_FLAG === 'LIFEVIP'"
    class="shMember_slide"
    :key="e.SPECIAL_FLAG"
  >
        <div class="banner_slide--content">
          <div class="banner_slide--wrap">
            // 挂载CouponSwiper组件
            <div class="banner_coupon--container">
              <CouponSwiper
                :couponList="e.couponList"
                :isMember="e.OPEN_FLAG === '1'"
                :memberType="'ZXVIP'"
              />
            </div>
          </div>
        </div>
  </SwiperSlide>
 </template>

<script setup lang="ts">
import CouponSwiper from "@/components/CouponSwiper.vue";
......
</script>

新增抢券、办理会员接口(src/api/index.ts)

// 抢券接口
const getPrize = (params = {}) => {
  return httpGet({
    url: `${BASEURL}getPrize.do?t=${date}`,
    params
  })
}

// 办理会员接口
const openMember = (params = {}) => {
  return httpGet({
    url: `${BASEURL}order.do?t=${date}`,
    params
  })
}
export default { homeInit, getPrize, openMember }

// src/utiils/data.json
{
 // 抢券接口
 "getPrize.do": {
    "code": 1000,
    "data": {
      "SHOW_TITLE": "铁粉朋友,为您送啥第3周打卡福利",
      "oneClickGetAll": [
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/GDcar.png",
          "TITLE": "高德打车券",
          "PRIZE_NO": "ONO002",
          "STATUS": "1"
        },
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/1GBN.png",
          "TITLE": "1GB通用流量 新人首单礼",
          "PRIZE_NO": "ONO004",
          "STATUS": "1"
        },
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/1GBYYX.png",
          "TITLE": "1GB流量兑换券",
          "PRIZE_NO": "ONO006",
          "STATUS": "0"
        }
      ]
    }
  },
  // 办理会员接口
  "order.do": {
    "code": 1000,
    "data": {
      "couponList": [
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/pinduoduo.png",
          "TITLE": "拼多多40元红包",
          "PRIZE_NO": "ONO001"
        },
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/GDcar.png",
          "TITLE": "高德打车券",
          "PRIZE_NO": "ONO002"
        },
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/1GBN.png",
          "TITLE": "1GB通用流量 新人首单礼",
          "PRIZE_NO": "ONO004"
        },
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/1GBYYX.png",
          "TITLE": "1GB流量兑换券",
          "PRIZE_NO": "ONO006",
          "SUB_MODULE": "zwCoupon"
        },
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/o5y.png",
          "TITLE": "5元话费满赠券",
          "PRIZE_NO": "ONO005"
        },
        {
          "MODULE_NAME": "oneClickGetAll",
          "IMAGE": "https://m.sd.10086.cn/act_service/resources/1532/images/o2y.png",
          "TITLE": "2元话费满赠券",
          "PRIZE_NO": "ONO003"
        },
        {
          "G_ID": "20200422zx8",
          "HONEY": "1",
          "IMG": "https://m.sd.10086.cn/sd_act_service/resources/1728/images/popup_1gbtyll20210615.png",
          "MAIN_NAME": "zx_1gb4",
          "NAME": "1GB通用流量",
          "QUOTA": "ll",
          "REMAKE": "20000",
          "STOCK_S": "ZT"
        },
        {
          "G_ID": "20200422zx8",
          "HONEY": "1",
          "IMG": "https://m.sd.10086.cn/sd_act_service/resources/1728/images/popup_1gbtyll20210615.png",
          "MAIN_NAME": "zx_1gb4",
          "NAME": "1GB通用流量",
          "QUOTA": "ll",
          "REMAKE": "20000",
          "STOCK_S": "ZT"
        }
      ]
    }
  }
}

Home新增弹窗(src/views/Home.vue)

<template>
<!-- 弹窗部分 -->
<!-- 办理尊享会员、生活服务弹窗 -->
  <div
    class="popup pop_handleMember"
    v-show="popType === 'handleZXMember' || popType === 'handleSHMember'"
  >
    <img src="../assets/popBg1.png" alt="" class="pop_img" />
    <div class="pop_title" v-show="popType === 'handleZXMember'">
      开通尊享会员 <span class="pop_subTitle">3元/月</span>
    </div>
    <div class="pop_title" v-show="popType === 'handleSHMember'">
      开通生活服务会员 <span class="pop_subTitle">10元/月</span>
    </div>
    <div class="pop_desc">即可秒杀大额话费</div>
    <div
      class="pop_btn"
      @click="handleMember(popType === 'handleZXMember' ? 'ZXVIP' : 'LIFEVIP')"
    >
      <img src="../assets/ceertainHandle.png" alt="" class="pop_btn--img" />
    </div>
    <img
      src="../assets/closeBtn.png"
      alt=""
      class="pop_close--img"
      @click="closePop"
    />
  </div>
  <!-- 办理会员成功 -->
  <div
    class="popup pop_memberSuc"
    v-if="popType === 'openMemberSuc' || popType === 'chooseOpenMemberSuc'"
  >
    <img src="../assets/openSuc.png" alt="" class="pop_img" />
    <div class="pop_title">&nbsp; 恭喜,办理成功!</div>
    <div class="pop_subTitle">快来领取权益吧</div>
    <div
      class="pop_btn"
      @click="
        setState({
          popType:
            popType === 'chooseOpenMemberSuc' ? 'oneKeyGetChoose' : 'oneKeyGet',
        })
      "
    >
      <img src="../assets/oneKeyGet.png" alt="" class="pop_btn--img" />
    </div>
    <img
      src="../assets/closeBtn.png"
      alt=""
      class="pop_close--img"
      @click="closePop"
    />
  </div>
</template>

<script lang="ts">
 setup() {
    const _this = getCurrentInstance()!;
    // 获取全局 跳转方法
    const { jumpLink, setState, $API, toast } =
      _this.appContext.config.globalProperties;
    onMounted(() => {
      $API.homeInit().then((res: any) => {
        if (res.code === 1000) {
          const { showModules, ...pageData } = res.data;
          // 获取页面数据、楼层数据
          state.pageData = pageData;
          state.floorData = showModules;
        }
      });
    });
    
        // 关闭弹窗
    const closePop = () => {
      setState({ popType: null });
    };
    // 办理会员
    const handleMember = (openFlag: string, popType: string | undefined) => {
      $API.openMember({ openFlag }).then((res: response) => {
        // 办理成功
        if (res.code === 1000) {
          // 获取会员楼层
          const vipFloor = state.floorData.find(
            (e: any) => e.MODULE_NAME === "vip"
          );
          // 获取会员模块、尊享/美食生活 修改被开通的模块状态
          const memberModule = vipFloor?.vipLists.find(
            (e: any) => e.SPECIAL_FLAG === openFlag
          );
          if (memberModule) memberModule.OPEN_FLAG = "1";
          // 修改用户是否开通会员的状态
          if (state.pageData.vipFlag !== "1") state.pageData.vipFlag = "1";
          // 区分   通过即将办理成功弹窗拉起 还是通过点击VIP楼层办理拉起
          setState({
            popType:
              popType === "upcoming" ? "chooseOpenMemberSuc" : "openMemberSuc",
            popData: res.data,
          });
        } else if (res.code === 1310) {
          setState({ popType: "charge" });
        } else if (res.code === 21005) {
          toast("抱歉,活动已下线~");
        } else {
          // 办理失败
          setState({ popType: "openMemberFail" });
        }
      });
    };
    // 使用toRefs处理reactive数据后,在解包reactive数据不会丢失响应式
    return { ...toRefs(state), floorType, handleMember, hClickNav, closePop };
 }
</script>

<style scoped lang="scss">
main {
  margin-top: -332px;
}
.popup_mask {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background-color: rgba(0, 0, 0, 0.7);
  z-index: 5;
}
.popup {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 100;
  font-family: FZLTHJW--GB1-0;
  .pop_title {
    position: absolute;
    top: 24px;
    left: 50%;
    transform: translateX(-50%);
    font-size: 48px;
    font-weight: bold;
    letter-spacing: 1px;
    color: #ff1c44;
  }
  .pop_punchGetEquity--subTitle {
    position: absolute;
    top: 91px;
    left: 50%;
    transform: translateX(-50%);
    font-size: 32px;
    color: #ff1c44;
    white-space: nowrap;
  }
  .pop_punchGetEquity--tip {
    position: absolute;
    bottom: 119px;
    left: 50%;
    transform: translateX(-50%);
    font-size: 24px;
    letter-spacing: 1px;
    white-space: nowrap;
    color: #ffffff;
  }
  .pop_couponWrap {
    position: absolute;
    top: 145px;
    left: 50%;
    transform: translateX(-50%);
    width: 645px;
    height: 250px;
  }
  .pop_couponWrap--item {
    position: absolute;
    top: 0;
    left: 0;
    width: 215px;
    height: 225px;
    transition: transform 0.15s;
    text-align: center;
    &:nth-child(2) {
      transform: translateX(100%);
    }
    &:last-child {
      transform: translateX(200%);
    }
  }
  .pop_couponWrap--img {
    display: inline-block;
    width: 184px;
  }
  .pop_punchGetEquity--btn {
    position: absolute;
    bottom: 24px;
    left: 50%;
    width: 322px;
    transform: translateX(-50%);
  }
  .pop_close--img {
    position: absolute;
    bottom: -100px;
    left: 50%;
    transform: translateX(-50%);
    width: 72px;
    height: 72px;
  }
}
.pop_handleMember {
  width: 531px;
  .pop_img {
    width: 531px;
  }
  .pop_title,
  .pop_desc {
    position: absolute;
    top: 276px;
    left: 50%;
    transform: translateX(-50%);
    font-family: FZLTHJW--GB1-0;
    font-size: 36px;
    font-weight: bold;
    color: #ffffff;
    display: flex;
    white-space: nowrap;
  }
  .pop_subTitle {
    margin-left: 25px;
  }
  .pop_desc {
    top: 357px;
    font-size: 32px;
    font-weight: normal;
  }
  .pop_btn {
    position: absolute;
    bottom: 63px;
    left: 50%;
    transform: translateX(-50%);
    width: 366px;
  }
  .pop_btn--img {
    width: 366px;
  }
}
.pop_memberSuc,
.pop_memberFail,
.pop_appoint,
.pop_limitAppoint,
.pop_charge {
  width: 531px;
  font-family: FZLTHJW--GB1-0;
  font-size: 36px;
  .pop_img {
    width: 531px;
  }
  .pop_title {
    font-weight: bold;
  }
  .pop_title,
  .pop_subTitle {
    top: 270px;
    font-size: 36px;
    color: #ffffff;
    white-space: nowrap;
  }
  .pop_subTitle {
    position: absolute;
    top: 350px;
    left: 0;
    width: 100%;
    text-align: center;
  }
  .pop_btn {
    position: absolute;
    bottom: 63px;
    left: 50%;
    transform: translateX(-50%);
    width: 366px;
  }
  .pop_btn--img {
    width: 366px;
  }
}
.pop_memberSuc {
  .pop_title {
    padding-left: 20px;
  }
}
</style>

其他楼层也是遵循这个设计模式
1、所有楼层抽离成组件
2、全局工具函数放入globalProperties中,这样任何组件模板中可直接使用
3、全局状态数据映射成计算属性(保持响应式),并全局混入到组件中(慎重使用全局混入)

开发中可能会遇到的坑

模板中无法识别全局的数据(混入/globalProperties)

我们前面通过混入挂载的vuex-state数据、globalProperties挂载全局数据在模板中直接使用可能会遇到TS报错

image.png

Vue3提供了一个自定义全局属性的接口ComponentCustomProperties,通过这个接口的拓展解决这个问题。

// src/store/iindex.ts
declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    popType: string;
    popData: any;
    setState: (arg0: object) => void
  }
}
// src/utils/tool.ts  
declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    insertCode: (arg: void) => void;
    jumpLink: (url: string) => void;
    toast: (content: string) => void;
  }
}

模板中无法识别TS语法

image.png

image.png 可以在声明props数据的地方声明数据的类型

// src/components/floor/VIP.vue  
<script setup lang="ts">
  ...
SwiperCore.use([Autoplay]);
// 声明props数据类型
const props = defineProps<{ floorData: { vipLists: any[] } }>();
....
</script>

项目打包部署在服务器页面空白

vite打包后的路径默认是根路径,如果是因为资源路径问题导致的页面空白。大概率是因为你忘记配置打包后的资源路径,你只需要在vite.config.js配置里写上 base: './' 即可轻松解决。

//  vite.config.js
export default defineConfig({
  // 构建时资源已相对路径引入
  base: './'
})