前端如何实现年度报告功能

3,552 阅读6分钟

1.需求描述

靖江市人民医院年度总结需求:

image.png

1.员工通过企业微信推送消息信息进入个人年度总结页面,本人仅看到自己对应的工作量内容。

2.指标项通过员工工作量年度统计表展现,需要数据开发将年度数据通过ETL工程录入员工工作量年度统计表。

3.个人年度总结页面为长图展现,支持保存为图片。

4.员工指标值为0或为空时,展示内容页或展示文字隐藏处理。

5.医师年度门诊量、医师收治病人量、医师医嘱数量增加排名内容,计算有工作量部分医师排名所处位置,显示为“你的工作量超过%?同事”。

6.增加年度账单埋点,记录用户代码,用户姓名,操作类型默认-“登录”,操作菜单默认-“年度账单”,操作时间。埋点情况可在用户使用统计中查询获得。

7.要有背景音乐,选曲《Summer》

因为这里主要讲述前端开发,对于需求中列出的相关字段和演示数据表格从略。

2. UI效果

image.png

image.png

image.png

image.png

image.png

image.png

image.png

3.开发过程

创建 annual-report 模块,并在组件中定义需要用到的变量和一些模板引用。

public isImageShow: boolean = false;
public loading: boolean = false;
public isMusicPlay: boolean = false;
public isEnableBgm: boolean = false;
public isAutoPlay: boolean = false;
public reportData: {string?: {string?: string}};
public reportSwiperIndex: {string?: number} = {};
public errorMessage: string;
public remNumber: number;
public refreshSubscription: Subscription;
public swiperDom: any;
public imgSrc = '';
public swiperIndex: number = 0;
public upOptions: ICountUpOptions = {
    useGrouping: false,
    duration: 1,
    useEasing: false
};

@ViewChild('imgBox', { static: false }) public imgBoxRef: ElementRef;
@ViewChild('audioMedia', { static: false }) public audioMediaRef: ElementRef;
@ViewChild('shareImg', { static: false }) public shareImgRef: ElementRef;

在构造函数时期,需要根据窗口resize设置 fontSize,从而利用rem布局;获取获取音乐播放器配置项;访问页面的埋点。

ps:对于埋点的方案设计,我会在以后的文章中叙述。

constructor(
    private _ngZone: NgZone,
    private _route: Router,
    private _authService: AuthService,
    private _configService: ConfigService,
    private _reportService: AnnualReportService,
    private _cdRef: ChangeDetectorRef,
    private _sanitizer: DomSanitizer,
  ) {
    // 设置fontSize
    this.countFontSize();
    this.refreshSubscription = fromEvent(window, 'resize')
    .pipe(
      debounceTime(500)
    )
    .subscribe(
      () => {
        this._countFontSize();
      }
    );
    // 获取音乐播放器配置项
    this.getMusicConfig();
    // 埋点
    ...
  }

3.1 设置fontSize

private _countFontSize(): void {
    const DESIGN_WIDTH = 375; // 设计稿大小
    this.remNumber = (document.documentElement.clientWidth * 100) / DESIGN_WIDTH;
    document.documentElement.style.fontSize = this.remNumber + 'px';
  }

另外,不要忘了在 ngOnDestroy 时期,重置 fontSize,并销毁 Subscription

public ngOnDestroy(): void {
  document.documentElement.style.fontSize = null;
  this.refreshSubscription.unsubscribe();
  // 还原title
  document.title = '数据分析平台';
}

3.2 获取音乐播放器配置项

我们需要在页面加载时,音乐播放器是处于自动播放状态。

这一步需要调用一个后端接口,接口返回的数据👇

image.png

这里注意下对于后端返回的数据的interface的设计,很多属性都是只读模式,不能去修改。

interface IGlobalConfig {
  ...
  readonly mobilebi: {
    readonly IsAutoPlay: boolean;
    readonly IsEnableBgm: boolean;
    readonly templateModify: ITemplateModify;
  };
  ...
}

interface ITemplateModify {
  readonly newTemplateName: string;
  readonly oldTemplateName: string;
  readonly topicList: string[];
}

设置音乐播放器配置项的方法,对于在service层调用接口的方法此处省略。

private getMusicConfig(): void {
    this._configService.getGlobalConfig()
      .subscribe((res) => {
        if (res['mobilebi']) {
          this.isEnableBgm = !!res.mobilebi['IsEnableBgm'];
          this.isAutoPlay = !!res.mobilebi['IsAutoPlay'];
          this.isMusicPlay = !!res.mobilebi['IsAutoPlay'];
        }
      });
  }

3.3 加载年度报告数据

在 ngOnInit 时期,需要加载年度报告数据。先看下后端返回的数据:

image.png

对后端返回的数据进行改造,最终赋给 reportData

public loadReportData(): void {
    this.loading = true;
    this._reportService.getReportData()
      .subscribe((res: IReportPageData) => {
        // 修改title
        document.title = res.title || `来自${new Date().getFullYear() - 1}年的一封信`;
        if (!res.pages || !res.pages.length) {
          this.reportData = null;
          this.reportSwiperIndex = {};
          this.errorMessage = '暂无数据';
          this.loading = false;
          return;
        }

        const reportObj = {};
        res.pages.forEach((r) => {
          const pageDetails = {};
          if (r.pageDetails && r.pageDetails.length) {
            r.pageDetails.forEach((detail) => {
              if (r.pageKey === 'greetingCardImg' && detail.key === 'imgPath') {
                pageDetails['_safeUrl'] = detail.value ? this._sanitizer.bypassSecurityTrustResourceUrl(detail.value) : '';
              }
              pageDetails[detail.key] = detail.value;
            });
          }
          pageDetails['pageTemplate'] = r.pageTemplate;
          reportObj[r.pageKey] = pageDetails;
        });
        // 用于动画下标定位
        this.reportSwiperIndex = {};
        Object.keys(reportObj)
          .forEach((key, index) => {
            this.reportSwiperIndex[key] = index;
          });
        // 报告数据赋值
        this.reportData = reportObj;
        // 监测数据变化更新视图
        this._cdRef.detectChanges();
        this.swiperInit();
        this.loading = false;
      }, (err) => {
        this.reportData = null;
        this.reportSwiperIndex = {};
        this.errorMessage = err.error || '页面加载出错,请刷新';
        this.loading = false;
      });
  }

reportData的数据结构:

image.png

annual-report.html 中的部分代码

swiper-wrapper 下面每个 div 对应七张图片的dom,每个dom都对应有自己的子组件。

<div #page class="annual-report-page swiper-container" *ngIf="reportData">
  <!-- 用于页面滑动展示 -->
  <div class="swiper-wrapper" [class.fixed]="isImageShow">
     <!-- 卷首语 -->
    <div class="swiper-slide" *ngIf="reportData['intro']">
      <bi-preface [data]="reportData['intro']"></bi-preface>
    </div>
    <!-- 年度接诊 -->
    <div class="swiper-slide" *ngIf="reportData['annualClinic']">
      <bi-outpatient-number
          [data]="reportData['annualClinic']"
          [index]="reportSwiperIndex['annualClinic']"
          [swiperIndex]="swiperDom ? swiperDom['activeIndex'] : 0">
      </bi-outpatient-number>
    </div>
    <!-- 年度出诊 -->
    <div class="swiper-slide" *ngIf="reportData['doctorVisit']">
      <bi-outpatient-days
          [data]="reportData['doctorVisit']"
          [index]="reportSwiperIndex['doctorVisit']"
          [swiperIndex]="swiperDom ? swiperDom['activeIndex'] : 0">
      </bi-outpatient-days>
    </div>
    <!-- 发热门诊 -->
    <div class="swiper-slide" *ngIf="reportData['feverClinic']">
      <bi-outpatient-fever-days
          [data]="reportData['feverClinic']"
          [index]="reportSwiperIndex['feverClinic']"
          [swiperIndex]="swiperDom ? swiperDom['activeIndex'] : 0">
      </bi-outpatient-fever-days>
    </div>
    <!-- 收治患者 -->
    <div class="swiper-slide" *ngIf="reportData['physiciansTreated']">
      <bi-admitted-number
          [data]="reportData['physiciansTreated']"
          [index]="reportSwiperIndex['physiciansTreated']"
          [swiperIndex]="swiperDom ? swiperDom['activeIndex'] : 0">
      </bi-admitted-number>
    </div>
    <!-- 书写相关记录 -->
    <div class="swiper-slide" *ngIf="reportData['doctorOrder']">
      <bi-medical-advice
          [data]="reportData['doctorOrder']"
          [index]="reportSwiperIndex['doctorOrder']"
          [swiperIndex]="swiperDom ? swiperDom['activeIndex'] : 0">
      </bi-medical-advice>
    </div>
    <!-- 贺卡寄语 -->
    <div class="swiper-slide" *ngIf="reportData['greetingCardImg']">
      <bi-bless
          [data]="reportData['greetingCardImg']"
          (createImageEvent)="createImageByCanvas()"
          [isImageShow]="isImageShow">
      </bi-bless>
    </div>
  </div>
  ...
 </div>
 
 <bi-empty-component *ngIf="!loading && !reportData"  data-html2canvas-ignore
  [errorMessage]="errorMessage"
  (refreshEvent)="loadReportData()"
></bi-empty-component>

<bi-loading *ngIf="loading" data-html2canvas-ignore></bi-loading>

3.4 初始化 Swiper

对于滑动特效,需要引入 Swiper 插件,我下载的是 6.4.5 版本。具体可以参考 swiper 官网

在组件中的引入:

import * as Swiper from 'swiper/swiper-bundle.js';

初始化 Swiper,返回初始化后的Swiper实例。Swiper7使用的默认容器是'.swiper',Swiper6以下使用的是'.swiper-container'。

设置一些属性,direction 用来指定滑动方向;observer 为 true 是用来启用动态检查器,即更改swiper 的样式(如隐藏/显示)或修改其子元素(如添加/删除幻灯片),每次 Swiper 都会更新(重新初始化);observeParents 为 true 是将observe应用于Swiper的祖先元素。当Swiper的祖先元素变化时,例如window.resize,Swiper更新。

public swiperInit(): void {
    this.swiperDom = new Swiper('.swiper-container', {
      direction: 'vertical',
      observer: true,
      observeParents: true
    });

    this._fixAutoPlay();
  }

3.5 实现音乐播放/暂停功能

当手指触摸到屏幕是会触发 touchstart 事件,即使已经有一根手指放在屏幕上也会触发,这时候用 addEventListener 来侦听该事件并处理相应的函数。

private _fixAutoPlay(): void {
    const play = () => {
      if (this.isMusicPlay && this.isAutoPlay && this.audioMediaRef.nativeElement.paused) {
        this.audioMediaRef.nativeElement.play();
      }
      if (!this.audioMediaRef.nativeElement.paused) {
        document.removeEventListener('touchstart', play, false);
      }
    };
    document.addEventListener('touchstart', play, false);
  }

因为七张图片都有音乐播放器功能,所以将这一块抽取出来放在父组件 annual-report.html 中

<div #page class="annual-report-page swiper-container" *ngIf="reportData">
  <!-- 用于页面滑动展示 -->
  <div class="swiper-wrapper" [class.fixed]="isImageShow">
  ...
  </div>
  
   <!-- 音乐播放器 -->
  <div #audioBtn class="audio-btn" [class.rotate]="isMusicPlay" (click)="changeMusicPlay($event)" *ngIf="isEnableBgm" data-html2canvas-ignore>
    <audio #audioMedia *ngIf="isAutoPlay" autoplay="autoplay" loop="loop" preload="auto" src="/assets/music/report-bg-music.mp3">
      Your browser does not support the audio tag
    </audio>
    <audio #audioMedia *ngIf="!isAutoPlay" loop="loop" preload="auto" src="/assets/music/report-bg-music.mp3">
      Your browser does not support the audio tag
    </audio>
  </div>
</div>

HTML5 规定了在网页上嵌入音频元素的标准,即使用 audio 标签。目前支持三种音频格式文件: MP3、Wav 和 Ogg。其中,autoplay 属性表示音频在就绪后马上播放;loop 属性表示每当音频结束时重新开始播放,即循环播放;preload 属性表示音频在页面加载时进行加载,并预备播放。

添加属性 data-html2canvas-ignore 表示在实现页面截图时忽略该元素。

在 assets 文件夹中放入需要的音乐mp3文件。

关于点击后播放/暂停的处理:

public changeMusicPlay(event: Event): void {
    if (event) {
      event.stopPropagation();
      event.preventDefault();
    }

    this.isMusicPlay = !this.isMusicPlay;
    if (this.isMusicPlay) {
        this.audioMediaRef.nativeElement.play();
    } else {
        this.audioMediaRef.nativeElement.pause();
    }
  }

点击播放后形成的 rotate 动画效果等less代码请看👇

.audio-btn {
    width: 0.3rem;
    height: 0.3rem;
    background-image: url(/assets/images/music-top.png);
    background-size: contain;
    background-repeat: no-repeat;
    background-position: center;
    position: fixed;
    top: 0.2rem;
    right: 0.2rem;
    &.rotate {
      background-image: url(/assets/images/music-play.png);
      -webkit-animation: rotating 2.5s linear infinite;
      -moz-animation: rotating 2.5s linear infinite;
      -o-animation: rotating 2.5s linear infinite;
      animation: rotating 2.5s linear infinite;
    }
    audio {
      display: none;
    }
  }

3.6 卷首语

在 components 文件夹下新建组件 preface

定义的变量和父组件传递过来的数据

@Input() public data: {string?: string};

public pageTemplate: {text: string, textType: string | string[]}[] = [];

在 ngOnChanges 时期,处理父组件传递过来的数据。

public ngOnChanges(changes: SimpleChanges): void {
    if (changes.data.currentValue) {
      this._handleTemplateData();
    }
}
  
private _handleTemplateData(): void {
    if (!this.data['pageTemplate']) {
      return;
    }

    const pageTemplate = JSON.parse(this.data['pageTemplate']);
    const regex = /\{(.+?)\}/g;
    pageTemplate.forEach(template => {
      if (template['textType'] === 'slogan') {
        return;
      }

      const matchList = template['text'].match(regex);
      if (!matchList) {
        return;
      }
      matchList.forEach(match => {
        const key = match.substr(1, match.length - 2);
        const html = `<span class="color-yellow${key === 'doctorName' ? ' name-text' : ''}">${this.data[key]}</span>`;
        template['text'] = template['text'].replace(match, html);
      });
    });

    this.pageTemplate = pageTemplate;
  }

可以在 ngOnChanges 时期打印参数 changes,观察父组件传递来的数据

image.png

pgeTemplate 完整JSON数据:

image.png

根据 textType 字段的不同,对 pgeTemplate 字符串进行分割处理

image.png

在 preface.html 中根据 textType 字段去处理对应的文字与样式。

<div class="preface-block">
  <div class="img-top">
    <img src="/assets/images/preface-foreground.png" alt="" />
  </div>
  <div class="text-block">
    <ng-container *ngFor="let template of pageTemplate">
      <ng-container *ngIf="template['textType'] === 'title'">
        <p style="line-height: 0.21rem" [innerHTML]="template.text"></p>
      </ng-container>
      <ng-container *ngIf="template['textType'] === 'paragraph'">
        <br />
        <p style="line-height: 0.20rem" [innerHTML]="template.text"></p>
      </ng-container>
      <ng-container *ngIf="template['textType'] === 'wrap'">
        <p style="line-height: 0.20rem" [innerHTML]="template.text"></p>
      </ng-container>
      <ng-container *ngIf="template['textType'] === 'slogan'">
        <br />
        <br />
        <p class="size-20" *ngFor="let text of template.text; let i = index" [class.align-right]="i%2 !== 0">{{text}}</p>
      </ng-container>
    </ng-container>
  </div>
  <div class="img-bottom">
    <img src="/assets/images/report-hospital-logo.png" alt="" />
  </div>
</div>

因为背景图片是👇,所以文字用绝对定位就行了。

image.png

3.7 年度接诊等五个页面

从年度接诊到书写记录等五张图片对应的子组件处理基本类似,这里以年度接诊页面为例。

在 components 文件中新建组件 outpatient-number,接收父组件的属性有:

@Input() public data: {string?: string};
@Input() public swiperIndex: number;
@Input() public index: number;

outpatient-number.html

<div class="outpatient-number-block">
  <div class="text-block">
    <p [class.active]="swiperIndex === index"
      style="top: .65rem;" 
      [style.padding-left]="swiperIndex === index ? '.22rem': 0">这一年,您共接诊了<span class="color-green big-fontsize">{{data['patientNum'] || 0}}</span>位患者</p>
    <p [class.active]="swiperIndex === index"
      style="top: 1.03rem; text-align: right;"
      [style.padding-right]="swiperIndex === index ? '0.4rem': 0">超过<span class="color-yellow big-fontsize">{{data['overPercentage'] || 0}}</span><span class="color-yellow">%</span>同事</p>
    <p [class.active]="swiperIndex === index"
      style="top: 1.71rem;"
      [style.padding-left]="swiperIndex === index ? '.38rem': 0"><span class="color-green">{{data['maxDay'] ? data['maxDay'].split('-')[1] : ''}}</span><span class="color-green">{{data['maxDay'] ? data['maxDay'].split('-')[2] : ''}}</span>日是您接诊患者最多的一天</p>
    <p [class.active]="swiperIndex === index"
      style="top: 2.10rem;"
      [style.padding-left]="swiperIndex === index ? '.38rem': 0">这一天您接诊了<span  class="color-green big-fontsize">{{data['maxDayPatientNum'] || 0}}</span>位患者</p>
    <p class="common-text active" style="top: 3.85rem; padding-left: 1.6rem">仁心仁术 徳医双馨</p>
  </div>
  <div class="img-bottom">
    <img src="/assets/images/outpatient-number-foreground.png" alt="" />
  </div>
</div>

3.8 贺卡寄语

最后一张图片对应的贺卡寄语,需要重点讲下过程。

因为要用到生成图片,即屏幕截图的功能,这里需要下载 html2canvas 插件,我用的是 1.0.0-rc.4 版本。在 annual-report.component.ts 中进行引入

import html2canvas from 'html2canvas';

详情参考 html2canvas 官网

父组件html:

<div #page class="annual-report-page swiper-container" *ngIf="reportData">
  <!-- 用于页面滑动展示 -->
  <div class="swiper-wrapper" [class.fixed]="isImageShow">
      ...
      ...
      <!-- 贺卡寄语-->
      <div class="swiper-slide" *ngIf="reportData['greetingCardImg']">
         <bi-bless
            [data]="reportData['greetingCardImg']"
            (createImageEvent)="createImageByCanvas()"
            [isImageShow]="isImageShow">
         </bi-bless>
      </div>
  </div>
  
  <!-- 用于生成长图,不显示 -->
  <div class="report-blocks" id="app" style="display: none;">
    <div class="block-item" *ngIf="reportData['intro']">
      <bi-preface [data]="reportData['intro']"></bi-preface>
    </div>
    <div class="block-item" *ngIf="reportData['annualClinic']">
      <bi-outpatient-number
          [data]="reportData['annualClinic']"
          [index]="reportSwiperIndex['annualClinic']"
          [swiperIndex]="reportSwiperIndex['annualClinic']"></bi-outpatient-number>
    </div>
    ...
    ...
    <div class="block-item" *ngIf="reportData['greetingCardImg']">
      <bi-bless [data]="reportData['greetingCardImg']"></bi-bless>
    </div>
  </div>
  
   <!-- 点击生成图片后,页面出现的mask区域 -->
  <div class="image-mask" [class.show]="isImageShow" (touchstart)="$event.stopPropagation()" (touchmove)="$event.stopPropagation()" data-html2canvas-ignore>
    <div class="shareImg-box">
      <div class="close-box">
        <span class="tip">长按图片保存分享</span>
        <Icon class="close-icon" [type]="'cross'" [size]="'md'" [color]="'#293750'" (click)="closeImage()"></Icon>
      </div>
      <div class="shareImgScroll" #imgBox>
        <img [src]="imgSrc" #shareImg/>
      </div>
    </div>
  </div>
  <!-- 音乐播放器-->
  <div>...</div>
</div>

在 components 文件夹下新建子组件 bless

bless.html

<div class="bless-block">
  <div class="img-top">
    <img src="/assets/images/report-hospital-logo.png" alt="" />
  </div>
  <div class="img-text">
    <img [src]="data['imgPath'] ? data['_safeUrl'] : '/assets/images/report-bless.png'" alt=""/>
  </div>
  <div class="action" *ngIf="!isImageShow" data-html2canvas-ignore>
    <button class="bi-btn bi-btn-primary save-image-btn" (click)="createImage()">生成图片</button>
  </div>
</div>

bless.component.ts

  @Input() public data: {string?: string};
  @Input() public isImageShow: boolean = false;
  @Output() public createImageEvent: EventEmitter<boolean> = new EventEmitter();

  public createImage(): void {
    this.createImageEvent.emit(true);
  }

点击页面中的生成图片时,会将 canvas 元素转换成图片png格式。其实 Html2canvas 不会实际上的截图,而是通过从DOM读取的足够信息去建立一个页面的展示镜像,从而生成 base64 的图片。

public createImageByCanvas(scaleBy: number = 2): void {
    if (this.imgSrc) { // 生成一次后不再重复生成
      this.isImageShow = true;
      // 移动账单生成图片的埋点
      ...
      return;
    }

    this.loading = true;
    // 获取想要转换的dom节点
    const dom = document.querySelector('#app') as HTMLElement;
    dom.style.display = 'block';
    html2canvas(dom, {
      backgroundColor: null,
      useCORS: true, // 使用跨域
      scale: scaleBy,
    })
    .then((canvas) => {
      dom.style.display = 'none';
      const context = canvas.getContext('2d');
      context.imageSmoothingEnabled = false;
      const url = canvas.toDataURL('image/png', 1.0); // base64数据
      this.imgSrc = url;
      this.shareImgRef.nativeElement.onload = () => {
        this.isImageShow = true;
        this._scrollThrottleTime();
        this.loading = false;
      };
      // 移动账单生成图片的埋点
      ...
    })
    .catch(() => {
      dom.style.display = 'none';
      this.loading = false;
    });
  }

ctx.imageSmoothingEnabled 用于设置图片是否平滑,也就是是否抗锯齿。默认设置 (true) 会造成图片模糊并且破坏图片原有的像素。

对 scroll 事件进行节流处理(下图圈住的滑动区域)

image.png

private _scrollThrottleTime(): void {
  this._ngZone.runOutsideAngular(() => {
    fromEvent(this.imgBoxRef.nativeElement, 'scroll')
    .pipe(
      throttleTime(200),
    );
  });
}

关闭分享页面:

public closeImage(): void {
   this.isImageShow = false;
   this.imgBoxRef.nativeElement.scrollTop = 0;
}