用户年度报告项目复盘

朱治龙
2024-04-06 / 0 评论 / 29 阅读 / 正在检测是否收录...

成果展示

一图胜千言,我们先用视频来看实现好的最终效果,如果感兴趣我们再往下面了解技术实现细节。

缘起

2023 年 12 月 20 日,网易云音乐 2023 年度听歌报告正式上线,一些是年度报告的部分截图:
网易云音乐年度报告部分截图
体验完网易云音乐的年度听歌报告后,感觉咱们也可以推出一款类似的报告,以下是跟运营同事的沟通截图:
跟运营同事聊天截图
运营同事效率也是相当的高,第二天一早便梳理了我们年度报告的脑图:
控制台用户年度报告脑图

做好基础的准备工作后,当天跟领导确认了本项目的可行性:
领导确认

得到了领导的认可,就可以开展下一步的工作了。接着运营同事针对脑图的数据做了进一步的细化,梳理了需求文档:
用户年度计算报告需求文档
运营同事在需求文档中规划了每页展示的内容,下一步就可以交给设计的同事开展相关页面的设计工作了,为此专门拉了个小群沟通相关设计细节:
建立专门的设计群沟通设计细节
不得不说负责设计的同事也是超级给力,很快就提供了高质量的设计稿:
初版设计稿
而且超级专业地提供了相关页面的动效设计说明:
动效设计说明

开干

编写设计文档

需求有了,设计也有了,我们就可以开工干活了。编码工作之前,我们需要根据需求和设计稿,进行技术相关的详细设计文档编写工作,这样才能更好的指导后续的编码开发工作,为此我详细编写了40页超过4000字的详细设计文档:
用户年度计算报告详设文档

规划年报数据

在上面的详设文档中详细的规划了每一页所需的数据,每一页数据的来源,以及每个用户年报数据的组织形式、存储方式。

有了这些数据做支撑后,就是怎么将这些数据按运营要求收集起来,由于数据分散在不同的业务系统里,如用户系统、计费系统、用户行为系统。需要将相关的数据收集后并归总,为此,我们设计了数据合并机制。

用户年报页面实现

现在需要的数据也有了,只需要将年报页面按照 UI 效果进行实施即可。由于整体项目采用前后端分离的机制,用户看到的年报页面也采用独立的 git 工程进行维护,完成代码后单独打包部署即可。

整个前端页面,使用 vite 进行工程构建,使用 vue3 进行界面驱动,使用 swiper + vant-ui 实现基本页面布局,使用 echarts + vue-echarts 实现工程的图表效果,使用 animate.css 实现界面动效,工程的package.json 信息如下:

{
  "name": "console-yearlyreport-ui",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "dev:blsc": "vite --mode blsc.development",
    "devIstio": "vite build --mode devIstio",
    "devIstio:blsc": "vite build --mode blsc.devIstio",
    "stage": "vite build --mode stage",
    "stage:blsc": "vite build --mode blsc.stage",
    "build": "vite build",
    "build:report": "vite build",
    "build:blsc": "vite build --mode blsc.production"
  },
  "dependencies": {
    "animate.css": "^4.1.1",
    "axios": "0.21.4",
    "dayjs": "^1.11.10",
    "echarts": "^5.4.3",
    "lodash": "^4.17.21",
    "pinia": "^2.1.7",
    "swiper": "^11.0.5",
    "vant": "^4.8.3",
    "vue": "^3.3.11",
    "vue-echarts": "^6.6.8"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.5.2",
    "rollup-plugin-visualizer": "^5.12.0",
    "sass": "^1.70.0",
    "terser": "^5.27.0",
    "vite": "^5.0.8",
    "vite-plugin-compression": "^0.5.1"
  }
}

前端工程的整体目录结构:
目录结构

下面就一些核心代码做一些梳理

main.js

该文件为项目入口文件,主要对环境做了些特殊处理,如未登录则跳转到登录页;PC端访问则跳转到扫码页面,引导用户使用手机扫码访问等。

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'animate.css'
import './style.css'
import '@/styles/index.scss'
import { registerComponents } from './components/index'
import initMatomo from './core/matomo/index.js'
import App from './App.vue'

const {
  VITE_APP_DIST_MODE: distMode
} = import.meta.env

/**
 * 检测浏览器环境,如果是PC端浏览器访问活动页则直接重定向到二维码扫码页面
 */
const checkBrowser = () => {
  // 根据user-agent 判断是否跳转到pc页面
  if (window.matchMedia && window.matchMedia("(min-width: 768px)").matches) {
    window.location.href = `pc_${distMode}.html`
    return false
  }
  return true
}
/**
 * 检测用户登录情况,如果localStorage 中不包含 token 则自动重定向到移动端授权页面
 */
const checkLogin = () => {
  const token = localStorage.getItem('token')
  if (!token) {
    localStorage.setItem('authForExternalPage', `/yearly-report-2023/` + location.search)
    location.href='/auth'
    return false
  }
  return true
}

const checkEnv = async () => {
  if (!checkBrowser()) {
    return
  }
  // // 检测用户登录情况
  if (!checkLogin()) {
    return
  }

  const pinia = createPinia()
  const app = createApp(App)
  // 注册全局组件
  registerComponents(app)
  app.use(pinia)
  app.mount('#app')
  initMatomo(app, null)

  // 根节点添加样式名,便于对部分样式根据主题进行灵活控制
  $('html').addClass(`theme-${distMode}`)

}
checkEnv()

App.vue

这是页面入口的核心文件,在这个文件里使用swiper组织了所有页面,并根据各种不同的条件做了不同展示处理(未登录、资源预加载时显示加载中、加载完成后展示封面页;封面页未勾选协议则无法滑动等); 并从整体上收集用户行为数据,完整代码如下:

<template>
  <div v-if="existReportData === false">
    <not-exist-data-page />
  </div>
  <div v-else-if="pageResourceLoading" class="page-loading">
    <van-circle
      v-model:current-rate="resourceLoadPercent"
      :rate="100"
      :speed="100"
      size="180px"
      :layer-color="themeColors.themeLight6"
      :color="gradientColor"
      :stroke-width="50"
      :text="resourceLoadPercent + '%'"
    />
    <p class="loading-tips-text">页面加载中,请稍候</p>
    <!-- <van-progress :stroke-width="8" style="width: 90%" color="linear-gradient(to right, #be99ff, #7232dd)" :percentage="resourceLoadPercent" /> -->
  </div>
  <swiper
    v-else
    :slides-per-view="1"
    :direction="'vertical'"
    :space-between="0"
    effect="fade"
    @swiper="onSwiper"
    @slideChange="onSlideChange"
    @slideChangeTransitionEnd="onSlideChangeTransitionEnd"
  >
    <swiper-slide class="swiper-page0" data-page-name="封面页"><enter-page /></swiper-slide>
    <swiper-slide class="swiper-page1" data-page-name="第一页"><page1 /></swiper-slide>
    <swiper-slide class="swiper-page2" data-page-name="第二页"><page2 /></swiper-slide>
    <!-- <swiper-slide class="swiper-page3" data-page-name="第三页"><page3 /></swiper-slide> -->
    <swiper-slide class="swiper-page4" data-page-name="第四页"><page4 /></swiper-slide>
    <swiper-slide class="swiper-page5" data-page-name="第五页"><page5 /></swiper-slide>
    <swiper-slide class="swiper-page6" data-page-name="第六页"><page6 /></swiper-slide>
  </swiper>
  <img
    v-show="showUpArrow"
    src="/images/icon_arrow_up.png"
    class="arrow-up animate__animated animate__infinite animate__fadeInUp animate__slower"
    alt="提示上划"
  />
</template>

<script setup>
import { defineComponent, onMounted, ref, watch } from "vue";
import { Swiper, SwiperSlide } from "swiper/vue";
import "swiper/css";
import { checkReportData } from './api/yearlyReportAPI'
import EnterPage from "./pages/EnterPage.vue";
import NotExistDataPage from "./pages/NotExistDataPage.vue";
import Page1 from "./pages/Page1.vue";
import Page2 from "./pages/Page2.vue";
// import Page3 from "./pages/Page3.vue";
import Page4 from "./pages/Page4.vue";
import Page5 from "./pages/Page5.vue";
import Page6 from "./pages/Page6.vue";
import { imagesLoad } from './core/imgLoad.min.js'
import resourceImgs from './assets/imgResources.json'
import themeColors from '@/core/themeColors.js'
import { getShortUserId, getQueryString } from './core/utils.js'

defineComponent({
  components: {
    Swiper,
    SwiperSlide,
  },
});


const existReportData = ref()

// 页面资源加载
const pageResourceLoading = ref(true)
const resourceLoadPercent = ref(0)
const gradientColor = {
  '0%': themeColors.themeLight3,
  '100%': themeColors.theme,
}

window.currentSwiper = null;

const currentSwiperIndex = ref(0);

const showUpArrow = ref(false);
watch(currentSwiperIndex, () => {

  if (window.currentSwiper === null) {
    return;
  }
  if (currentSwiperIndex.value === 0) {
    window.currentSwiper.disable();
  }
  // 封面页和最后一页不显示向上箭头
  if (currentSwiperIndex.value === 0 || currentSwiperIndex.value === window.currentSwiper.slides.length - 1) {
    showUpArrow.value = false;
    return;
  }
  showUpArrow.value = true;
});

const clearAnimation = () => {
  const prevSlideEl = window.currentSwiper.slides[window.currentSwiper.previousIndex];
  $(prevSlideEl)
    .find(".js-animation")
    .each(function (idx, item) {
      const $this = $(item);
      const animation = $this.attr("data-animation");
      $this
        .removeClass("animate__animated")
        .removeClass("animate__" + animation)
        .removeAttr("style");
    });
};
const addAnimation = () => {
  clearAnimation();
  const currentSlideEl = window.currentSwiper.slides[window.currentSwiper.activeIndex];
  $(currentSlideEl)
    .find(".js-animation")
    .each(function (index, item) {
      var $this = $(item);
      var animation = $this.attr("data-animation");
      var duration = $this.attr("data-duration");
      var delay = $this.attr("data-delay");
      $this
        .addClass("animate__animated")
        .addClass("animate__" + animation)
        .css({ "animation-duration": duration + "s", "animation-delay": delay + "s" });
    });
    // 入口页灯效单独处理
    if (currentSwiperIndex.value === 0) {
      const $el = $('.swiper-page0 .bg-light')
      if ($el.hasClass('hide')) {
        setTimeout(() => {
          $el.removeClass('hide')
        }, 1500)

      }
    }
};
/**
 * 添加Matomo 事件埋点
 */
const addMatomoEvent = () => {
  const pageName = $('.swiper-slide-active').attr('data-page-name')
  if (pageName) {
    window._paq && window._paq.push(['trackEvent', '2023年度报告', '查看', pageName])
  }
}
/**
 * 根据URL参数添加用户来源埋点
 */
const addSourceEvent = () => {
  const sourceMap = {
    direct: '直接访问',
    cloud: '云桌面',
    console: 'PC端控制台',
    mconsole: '移动端控制台',
    kbs: 'KBS内容页',
    email: '邮件海报'
  }
  const sourceInQuery = getQueryString('utm_source')
  let fromLabel = '直接访问'
  if (sourceInQuery) {
    const sourceLabel = sourceMap[sourceInQuery]
    if (sourceLabel) {
      fromLabel = sourceLabel
    }
  }
  window._paq && window._paq.push(['trackEvent', '2023年度报告', '来源', fromLabel])
}
const onSwiper = (s) => {
  addMatomoEvent()
  addSourceEvent()
  // 默认不允许用户滑动,仅同意协议并单击「点击回顾」按钮后才可滑动
  s.disable();
  if (window.currentSwiper === null) {
    window.currentSwiper = s;
  }
  addAnimation();
};
const onSlideChange = () => {
  currentSwiperIndex.value = window.currentSwiper.activeIndex;

  addAnimation();
  if (currentSwiperIndex.value === 1) {
    $('.swiper-page0 .bg-light').addClass('hide')
  }
};
const onSlideChangeTransitionEnd = () => {
  addMatomoEvent()
}
onMounted(async () => {
  // 检测用户数据
  const checkRes = await checkReportData()
  if (checkRes.exist === false) {
    existReportData.value = false
    window._paq && window._paq.push(['trackEvent', '2023年度报告', '查看', '无数据页'])
    return
  }
  if (checkRes.lastUpdateTime) {
    localStorage.setItem('yearlyReportDataUpdateTime_' + getShortUserId(), checkRes.lastUpdateTime)
  }
  new imagesLoad(resourceImgs, function (num) {
    resourceLoadPercent.value = num
  }, function () {
    resourceLoadPercent.value = 100
    setTimeout(() => {
      pageResourceLoading.value = false
    }, 300)
  })
})
</script>

<style lang="scss" scoped>
.page-loading {
  width: 100%;
  height: 100vh;
  padding-bottom: 20vh;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: $theme-light-8;
  // background-color: #F8FDFF;

}
.loading-tips-text {
  font-size: 16px;
  color:$theme-light-6;
  font-weight: bold;
  text-align: center;
}
.swiper {
  width: 100%;
  height: 100vh;
}
.swiper-slide {
  height: 100vh;
}
.arrow-up {
  position: absolute;
  width: 30px;
  margin-left: -15px;
  left: 50%;
  bottom: 30px;
  z-index: 100;
  pointer-events: none;
}
</style>

优先从本地缓存获取用户年报数据

用户年度计算报告的数据属于生成后基本上不会怎么变化的数据,而这种活动一般也是短期的,为减少活动推广期间可能出现的高并发场景对服务器资源的损耗,拟将用户指标数据在第一次获取后缓存在客户端,客户端存在用户缓存数据的情况下将直接使用缓存数据进行界面展示,流程如下图所示:
s数据缓存机制
核心代码如下:

/**
 * 截取部分userId用于作为用户特征串
 */
export function getShortUserId() {
  const userId = localStorage.getItem('userid')
  if (userId) {
    if (userId.length > 40) {
      return userId.substring(0, 40)
    }
    return userId
  }
}

/**
 * 获取用户年报数据,优先从缓存里取
 */
export async function getUserReportData() {
  const shortUserId = getShortUserId()
  const reportDataInLocal = localStorage.getItem('yearlyReportData_' + shortUserId)
  if (reportDataInLocal) {
    const reportDataObj = JSON.parse(reportDataInLocal)
    const lastUpdateTimeInLocal = reportDataObj.lastUpdateTime
    const lastUpdateTimeFromServer = localStorage.getItem('yearlyReportDataUpdateTime_' + shortUserId)
    if(lastUpdateTimeInLocal === lastUpdateTimeFromServer) {
      return reportDataObj.reportData
    }
  }
  const res = await getReportData()
  if (res.reportData) {
    res.reportData = JSON.parse(res.reportData)
    localStorage.setItem('yearlyReportData_' + shortUserId, JSON.stringify(res))
  }
  return res.reportData
}

UI 效果

以下图片为实际实现好的界面截图

并行版本

北龙版本

运营后台

为了便于运营人员核对用户指标数据及预览用户年报效果,我们使用 MagicBoot 快速开发了一个运营后台,相关界面效果如下:

运营大屏

为了便于运营人员及时了解用户访问大屏情况,我们使用 DataGear 制作了一个运营大屏,界面效果如下:
运营大屏展示效果

详设文档

本活动的技术开发工作能顺利开展,前期的详细设计是重中之重,详细设计中把方方面面的问题都考虑到了,在开发和上线阶段才能做到从容不迫,以下是详设文档附件(由于部分材料涉密,该文档仅部分人员可访问):

以下内容已加密,请输入密码查看:

1

评论 (0)

取消