1.需求描述
靖江市人民医院年度总结需求:
1.员工通过企业微信推送消息信息进入个人年度总结页面,本人仅看到自己对应的工作量内容。
2.指标项通过员工工作量年度统计表展现,需要数据开发将年度数据通过ETL工程录入员工工作量年度统计表。
3.个人年度总结页面为长图展现,支持保存为图片。
4.员工指标值为0或为空时,展示内容页或展示文字隐藏处理。
5.医师年度门诊量、医师收治病人量、医师医嘱数量增加排名内容,计算有工作量部分医师排名所处位置,显示为“你的工作量超过%?同事”。
6.增加年度账单埋点,记录用户代码,用户姓名,操作类型默认-“登录”,操作菜单默认-“年度账单”,操作时间。埋点情况可在用户使用统计中查询获得。
7.要有背景音乐,选曲《Summer》
因为这里主要讲述前端开发,对于需求中列出的相关字段和演示数据表格从略。
2. UI效果
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 获取音乐播放器配置项
我们需要在页面加载时,音乐播放器是处于自动播放状态。
这一步需要调用一个后端接口,接口返回的数据👇
这里注意下对于后端返回的数据的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 时期,需要加载年度报告数据。先看下后端返回的数据:
对后端返回的数据进行改造,最终赋给 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的数据结构:
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,观察父组件传递来的数据
pgeTemplate 完整JSON数据:
根据 textType 字段的不同,对 pgeTemplate 字符串进行分割处理
在 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>
因为背景图片是👇,所以文字用绝对定位就行了。
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 事件进行节流处理(下图圈住的滑动区域)
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;
}