使用 Echarts 实现项目进度甘特图

 Echarts 功能十分强大,可以实现多种图表效果,下面简单介绍下最近使用 Eharts 实现的一个项目进度甘特图。

下面是实现的效果:

目录

一.概览 Echarts 基本内容

1.官网文档

2.查看示例

二.需求分析

1.数据系列(series)

2.标线 (markLine)

3.数据对象结构

4.“超时”、“按时”划分

三.引入并配置 Echarts

1.坑

2.具体操作

四.说明


一.概览 Echarts 基本内容

1.官网文档

首先基本的前端搭建必须的,然后应该大致浏览一下 Echarts 官网 文档 专题下的 教程 和 配置项手册,如果不是要实现特别复杂的效果和功能,这些内容基本就足够了。

2.查看示例

只看文字内容比较抽象,因为要做甘特图,可以先去 实例 中找一个甘特图研究下,看看别人是怎么实现的。你会发现Echarts官网好像只能在 实例 下找到官方示例图,别急,操作一番就可以看其他无私的贡献者发布的图表了。

点击官网页面右上角 “EN”,切换为英文版,

在 Get Started 下 点击 Gallery,

这样就能看到其他开发者贡献的 Demo 了,

点击 展开筛选,筛选你要找的图表类型。

下面这个图表(点击这里查看该图表Demo)和我们要实现的效果还是有几分类似的:

当然细节还得自己研究修改一下。

二.需求分析

从文章开头的图表效果来看,横坐标刻度值为时间,纵坐标为 “方案”、“纲要”、“成果” 三个项目阶段类目,每个阶段类目中包含按时完成(蓝色柱状图)和超时完成(红色柱状图)两种系列的数据,另外还有垂直于 x 时间坐标轴的 “计划开始时间”和“有效期”两条时间标线。结合查阅官网文档和demo,应该有以下初步结论:

1.数据系列(series)

每个阶段类目中实际应该有三种系列(series)的数据的,即应该有 开始时间、按时完成时间和超时完成时间三种系列,且三种系列的柱状图是堆叠在一起的。

2.标线 (markLine)

“计划开始时间” markLine 可以放在在 开始时间series 中,“有效期”markLine可以在 按时完成 或 超时完成 series 中。

3.数据对象结构

这里比较容易理解的传值结构,应该是传入 三个时间段 和 两个刻度值,类似下面这种结构:

/**
 * 存储阶段进度的对象
 */
var stageProgress = {
	// 方案 实际时间段
	fangAnTimeBucket: ['2017-01-01', '2017-03-01'],
	// 纲要 实际时间段
	gangYaoTimeBucket: ['2017-02-26', '2017-08-29'],
	// 成果 实际时间段
	chengGuoTimeBucket: ['2017-08-29', '2017-12-06'],
	// 计划开始时间
	scheduledStartTime: '2016-12-22',
	// 有效截止日期
	validTime: '2017-05-10'
};

4.“超时”、“按时”划分

这里要求三个阶段实际时间段的首尾可以不连续,即三个阶段之间没有影响,所以,是否超时就取决于 有效期 与 阶段实际时间段 之间的关系了。比如,有效期在阶段实际时间段开始和结束时间之间,那么该阶段是超时的,但是该阶段的柱状图应该是蓝色红色共存的;有效期在阶段实际时间段结束时间之后,那么该阶段按时完成,应该都是蓝色的;有效期在阶段实际时间段开始时间之前,那么该阶段超时完成,应该都是红色的。

其实主要是第四点需要计算一下,其他 参考一下 配置项手册基本没有太大问题。

三.引入并配置 Echarts

1.坑

用新不用旧,但是经过我的测试,Echarets4.x 透明堆叠不起效果,即设置了 series 为 bar ,且 开始时间、按时完成时间、按时完成时间 三个 series 设置了 相同的 stack 后,开始时间 柱状图并不能起到遮盖其他系列柱状图的效果,于是尝试使用3.x 版本,是可以起到柱条堆叠辅助隐藏的效果的。(请同学们注意!!!评论区的seuedu同学指出,在4.x版本中,series里配置z属性可以覆盖,但本人未进行验证,请大家自行验证下!!!

若果需要兼容 IE8 的话,那么你需要定制下载  Echarts(我在实际测试中发现,这个定制下载即使选中 兼容IE8 ,下载下来的js仍让无法兼容 IE8,真的是让人脑壳痛)。

2.具体操作

在你的页面中,放置一个 div,作为放置图表的容器,需要为它指定宽高。

<!-- 为ECharts准备一个具备大小(宽高)的Dom -->
<div id="content"></div>

控制图表的 js 代码基本结构如下:

// 基于准备好的dom,初始化echarts实例
        var myChart = echarts.init(document.getElementById('main'));

        // 指定图表的配置项和数据
        var option = {
            title: {
                text: 'ECharts 入门示例'
            },
            tooltip: {},
            legend: {
                data:['销量']
            },
            xAxis: {
                data: ["衬衫","羊毛衫","雪纺衫","裤子","高跟鞋","袜子"]
            },
            yAxis: {},
            series: [{
                name: '销量',
                type: 'bar',
                data: [5, 20, 36, 10, 10, 20]
            }]
        };

        // 使用刚指定的配置项和数据显示图表。
        myChart.setOption(option);

下面是实现本文开头图表效果的 js,方法说明和需要注意的点基本都写在注释里了:

/**
 * 存储阶段进度时间段的对象
 */
var stageProgress = {
// 			// 方案 实际时间段
// 			fangAnTimeBucket: ['2017-01-01', '2017-03-01'],
// 			// 纲要 实际时间段
// 			gangYaoTimeBucket: ['2017-02-26', '2017-08-29'],
// 			// 成果 实际时间段
// 			chengGuoTimeBucket: ['2017-08-29', '2017-12-06'],
// 			// 计划开始时间
// 			scheduledStartTime: '2016-12-22',
// 			// 有效截止日期
// 			validTime: '2017-05-10'

	// 		// 方案 实际时间段
	// 		fangAnTimeBucket: ['2017-01-01', '2017-01-20'],
	// 		// 纲要 实际时间段
	// 		gangYaoTimeBucket: ['2017-02-07', '2017-02-28'],
	// 		// 成果 实际时间段
	// 		chengGuoTimeBucket: ['2017-02-15', '2017-03-20'],
	// 		// 计划开始时间
	// 		scheduledStartTime: '2017-01-01',
	// 		// 有效截止日期
	// 		validTime: '2017-02-20'

	// 方案 实际时间段
	fangAnTimeBucket: ['2017-11-23', '2017-11-28'],
	// 纲要 实际时间段
	gangYaoTimeBucket: ['2017-11-25', '2017-11-30'],
	// 成果 实际时间段
	chengGuoTimeBucket: ['2017-12-06', '2017-12-12'],
	// 计划开始时间
	scheduledStartTime: '2017-11-23',
	// 有效截止日期
	validTime: '2017-12-02'
};

/**
 * 横坐标轴时间刻度可选值
 * 这里 month和year 没有考虑平闰年之分
 */
var timeInterval = {
	day: 3600 * 1000 * 24,
	month: 3600 * 1000 * 24 * 31,
	year: 3600 * 1000 * 24 * 31 * 12,
};

/**
 * 时间坐标轴标签单位应该精确到哪一位
 */
var xAxisLabelUnit = {
	year: false,
	month: false,
	day: false
}

/**
 * 获取合适的横坐标时间刻度间隔
 */
function getProperTimeAxisInterval() {
	xAxisLabelUnit.year = false;
	xAxisLabelUnit.month = false;
	xAxisLabelUnit.day = false;

	var timeDataArray = getXAxisData();
	var begin = getTimeMilliseconds(timeDataArray[timeDataArray.length - 1]);
	console.log("begin " + begin);
	var periodMillis = getTimeMilliseconds(timeDataArray[timeDataArray.length - 1]) - getTimeMilliseconds(timeDataArray[0]);
	console.log("periodMillis " + periodMillis);
	var years = periodMillis / timeInterval.year;
	console.log("years " + years);
	var months = periodMillis / timeInterval.month;
	console.log("months " + months);
	var days = periodMillis / timeInterval.day;
	console.log("days " + days);

	if (months <= 1) {
		xAxisLabelUnit.day = true;
		return timeInterval.day * 2;
	} else if (months <= 16) {
		xAxisLabelUnit.month = true;
		return timeInterval.month;
	} else if (months <= 24) {
		xAxisLabelUnit.month = true;
		return timeInterval.month * 2;
	} else if (years <= 16) {
		xAxisLabelUnit.year = true;
		return timeInterval.year;
	}
}

/**
 * 获取横轴坐标数据源,这里横坐标只显示年月
 * 最小值取传入数据最小的时间再减小一个月
 * 最大值取传入数据最小的时间再增加一个月
 */
function getXAxisData() {
	var arr = new Array();
	arr = arr.concat(stageProgress.scheduledStartTime)
		.concat(stageProgress.fangAnTimeBucket)
		.concat(stageProgress.gangYaoTimeBucket)
		.concat(stageProgress.chengGuoTimeBucket)
		.concat(stageProgress.validTime).filter(function(item) {
			return item != "-";
		}).sort();
	console.log(arr);
	return arr;
}

/**
 * 更改日期字符串为相应月份的第一天
 * @param {Object} dateStr 日期字符串
 */
function changeDateToMonthFirstDay(dateStr) {
	var inputDate = new Date(dateStr);
	inputDate.setDate(1);
	var result = inputDate.getFullYear() + "-" +
		(inputDate.getMonth() >= 9 ? inputDate.getMonth() + 1 : "0" +
			(inputDate.getMonth() + 1)) + "-" + ("0" + 1);
	return result;
}

/**
 * 获取格式化的日期 YYYY-MM-dd
 */
function formatDateToStr(date) {
	var inputMonth = date.getMonth();
	var inputDate = date.getDate();
	var result = date.getFullYear() +
		"-" + (inputMonth >= 9 ? inputMonth + 1 : "0" + (inputMonth + 1)) +
		"-" + (inputDate >= 9 ? inputDate : "0" + (inputDate));
	return result;
}

var faOnTimeCompletionTime = getOnTimeCompletionTime('方案', stageProgress.fangAnTimeBucket[0], stageProgress.fangAnTimeBucket[
	1]);
var gyOnTimeCompletionTime = getOnTimeCompletionTime('纲要', stageProgress.gangYaoTimeBucket[0], stageProgress.gangYaoTimeBucket[
	1]);
var cgOnTimeCompletionTime = getOnTimeCompletionTime('成果', stageProgress.chengGuoTimeBucket[0], stageProgress.chengGuoTimeBucket[
	1]);

var faOverTimeCompletionTime = getOverTimeCompletionTime('方案', stageProgress.fangAnTimeBucket[0], stageProgress.fangAnTimeBucket[
	1]);
var gyOverTimeCompletionTime = getOverTimeCompletionTime('纲要', stageProgress.gangYaoTimeBucket[0], stageProgress.gangYaoTimeBucket[
	1]);
var cgOverTimeCompletionTime = getOverTimeCompletionTime('成果', stageProgress.chengGuoTimeBucket[0], stageProgress.chengGuoTimeBucket[
	1]);

/**
 * 时间数组
 */
var timeArray = {
	// 开始时间
	beginTimeArr: [
		getTimeMilliseconds(stageProgress.fangAnTimeBucket[0]),
		getTimeMilliseconds(stageProgress.gangYaoTimeBucket[0]),
		getTimeMilliseconds(stageProgress.chengGuoTimeBucket[0]),
	],
	// 按时完成时间
	onTimeCompletionTimeArr: [
		getTimeMilliseconds(faOnTimeCompletionTime),
		getTimeMilliseconds(gyOnTimeCompletionTime),
		getTimeMilliseconds(cgOnTimeCompletionTime),
	],
	// 超时完成时间
	overTimeCompletionTimeArr: [
		getTimeMilliseconds(faOverTimeCompletionTime),
		getTimeMilliseconds(gyOverTimeCompletionTime),
		getTimeMilliseconds(cgOverTimeCompletionTime),
	],
};

// 初始化图表
var myChart = echarts.init(document.getElementById('content'));

// 构建图表配置项
option = {
	tooltip: {
		trigger: 'axis',
		axisPointer: {
			type: 'shadow',
		},

		/**
		 * 也可以使用 formatter: '{b0}:<br />{a0}: {c0}<br />{a1}: {c1}<br />{a2}: {c2}',
		 * 但是这样当鼠标指向纵坐标的三个阶段中的某一个时,即使该阶段 没有按时完成,或者 没有超时,
		 * 也会显示 按时 或 超时 的 tooltip
		 */
		formatter: function(params) {
// 			console.log("params[0]" + JSON.stringify(params[0]));
// 			console.log("params[1]" + JSON.stringify(params[1]));
			console.log("params[2]" + JSON.stringify(params[2]));
			var info = params[0].axisValue + ":<br />";
			info += params[0].seriesName + ":" + getSeriesDateStr(params[0].data) + "<br />";
			info += "结束时间:" + (params[2].data != "-" && params[2].data != undefined && params[2].data != null&&!isNaN(params[2].data) ?
				getSeriesDateStr(params[2].data) :
				(!isNaN(params[1].data)&&params[1].data != undefined && params[1].data != null ? getSeriesDateStr(params[1].data) : "-"));
			return info;
		},
	},

	/**
	 * 右上角工具栏
	 */
	toolbox: {
		right: '3%',
		show: false,
		feature: {
			/**
			 * 数据视图
			 */
			// 			dataView:{
			// 				show:true,
			// 			},
			saveAsImage: {
				show: true
			}
		}
	},

	/**
	 * 图例
	 */
	legend: {
		data: ['按时完成', '超时完成'],
		tooltip: {
			show: true,
		},
		itemWidth: 15,
		itemHeight: 15,
		textStyle: {
			fontSize: 16,
			fontFamily: 'Microsoft YaHei',
		},
		itemGap: 120,
		padding: 10,
	},

	/**
	 * 直角坐标系内绘图网格
	 */
	grid: {
		left: '3%',
		right: '3%',
		bottom: '2%',
		/**
		 * grid 区域是否包含坐标轴的刻度标签。
		 */
		containLabel: true,
	},

	/**
	 * 横坐标
	 */
	xAxis: {
		// 		/**
		// 		 * 坐标轴指示器
		// 		 */
		// 		axisPointer: {
		// 			show: true,
		// 		},

		/**
		 * 时间轴,适用于连续的时序数据,与数值轴相比时间轴带有时间的格式化,
		 * 在刻度计算上也有所不同,例如会根据跨度的范围来决定使用月,星期,日还是小时范围的刻度。
		 */
		type: 'time',

		/**
		 * value 是一个包含 min 和 max 的对象,分别表示数据的最大最小值,这个函数应该返回坐标轴的最大值。
		 *
		 * 坐标轴刻度最小值。
		 */
		min: function(value) {
			return value.min + (getTimeMilliseconds(getProperTimeAxisBeginAndEndTime()[0]) - value.min);
		},

		/**
		 * value 是一个包含 min 和 max 的对象,分别表示数据的最大最小值,这个函数应该返回坐标轴的最大值。
		 *
		 * 坐标轴刻度最大值。
		 */
		max: function(value) {
			return value.max + (getTimeMilliseconds(getProperTimeAxisBeginAndEndTime()[1]) - value.max);
		},
		// 
		/**
		 * 设置坐标轴分割间隔
		 */
		interval: getProperTimeAxisInterval(),
		axisLine: {
			lineStyle: {
				color: '#BDC8CD',
				width: 1,
			},
		},

		/**
		 * 坐标轴刻度标签的相关设置。
		 */
		axisLabel: {
			showMinLabel: false,
			showMaxLabel: false,
			rotate: 35,
			margin: 12,
			fontSize: 16,
			color: '#1c2431',
			formatter: function(value, index) {
				var date = new Date(value);
				// var time = date.getFullYear() + "." + (date.getMonth() + 1) + "." + date.getDate();
				var time = date.getFullYear();
				if (xAxisLabelUnit.month) {
					time += "." + (date.getMonth() + 1);
				}
				if (xAxisLabelUnit.day) {
					time += "." + (date.getMonth() + 1) + '.' + date.getDate();
				}
				return time;
			},
		},

		/**
		 * 坐标轴刻度分割线
		 */
		splitLine: {
			show: false,
		},
	},

	/**
	 * 纵坐标
	 */
	yAxis: {
		type: 'category',
		data: ['方\n案', '纲\n要', '成\n果'],
		axisTick: {
			show: false,
		},
		axisLine: {
			lineStyle: {
				color: '#e9e9ea',
				width: 1,
			},
		},
		axisLabel: {
			fontWeight: 'bold',
			fontSize: 16,
			color: '#1c2431',
			fontFamily: 'Microsoft YaHei',
		},
		splitLine: {
			show: true,
			lineStyle: {
				color: '#eaeae9',
				width: 1,
			},
		},
	},

	/**
	 * 系列
	 */
	series: [{
		name: '开始时间',
		type: 'bar',
		stack: '时间',
		itemStyle: {
			normal: {
				barBorderColor: 'rgba(0,0,0,0)',
				color: 'rgba(0,0,0,0)'
			},
			emphasis: {
				barBorderColor: 'rgba(0,0,0,0)',
				color: 'rgba(0,0,0,0)'
			}
		},
		label: {
			normal: {
				formatter: function(params) {
					return getSeriesDateStr(params.value);
				},
				show: true,
				position: 'insideRight',
				fontSize: 16,
				color: '#1c2431',
				fontFamily: 'Microsoft YaHei',
				offset: [40, -20],
			}
		},
		data: timeArray.beginTimeArr,

		/**
		 * 标注线
		 */
		markLine: {
			lineStyle: {
				normal: {
					color: '#0f77e9',
				},
			},
			label: {
				normal: {
					fontWeight: 'bold',
					padding: 2,
					fontSize: 14,
					fontFamily: 'Microsoft YaHei',
					formatter: function(params) {
						return '计划开始时间 ' + getSeriesDateStr(params.value);
					},
				},
			},
			data: [{
				name: '计划开始时间',
				xAxis: getTimeMilliseconds(stageProgress.scheduledStartTime),
			}, ]
		},
	}, {
		name: '按时完成',
		type: 'bar',
		stack: '时间',
		itemStyle: {
			normal: {
				color: '#0f77e9'
			}
		},
		label: {
			normal: {
				formatter: function(params) {
					return getSeriesDateStr(params.value);
				},
				show: true,
				fontSize: 16,
				color: '#1c2431',
				position: 'right',
				fontFamily: 'Microsoft YaHei',
				offset: [-45, -20],
			}
		},
		data: timeArray.onTimeCompletionTimeArr,
		markLine: {
			lineStyle: {
				normal: {
					color: '#ff4747',
				},

			},
			label: {
				normal: {
					fontWeight: 'bold',
					padding: 2,
					fontSize: 14,
					fontFamily: 'Microsoft YaHei',
					formatter: function(params) {
						return '计划完成时间 ' + getSeriesDateStr(params.value);
					},
				},
			},
			data: [{
				name: '有效期',
				xAxis: getTimeMilliseconds(stageProgress.validTime),
			}, ]
		},
	}, {
		name: '超时完成',
		type: 'bar',
		stack: '时间',
		itemStyle: {
			normal: {
				color: '#ff4747'
			}
		},
		label: {
			normal: {
				formatter: function(params) {
					return getSeriesDateStr(params.value);
				},
				show: true,
				fontSize: 16,
				color: '#1c2431',
				offset: [-45, -20],
				position: 'right',
				fontFamily: 'Microsoft YaHei',
			}
		},
		data: timeArray.overTimeCompletionTimeArr,
		/**
		 * 柱状图宽度
		 */
		barWidth: 20,
	}, ]
};

// 将构建好的配置项传入echarts
myChart.setOption(option);

/**
 * 时间对象转日期字符串 yyyy.MM.dd
 * @param {Object} timeObject 毫秒值或时间字符串
 */
function getSeriesDateStr(timeObject) {
	if (timeObject == "-") {
		return timeObject;
	}
	var date = new Date(timeObject);
	var dateStr = '';
	dateStr += date.getFullYear() + '.';
	dateStr += date.getMonth() + 1 + '.';
	dateStr += date.getDate();
	return dateStr;
};

/**
 * 获取阶段的计划内完成时间(蓝色柱状图值)
 * @param {Object} stage 阶段
 * @param {Object} stateBeginTime
 * @param {Object} stateCompletionTime
 */
function getOnTimeCompletionTime(stage, stageBeginTimeStr, stageCompletionTimeStr) {
	var validTimeMillis = getTimeMilliseconds(stageProgress.validTime);
	var stageBeginTimeMillis = getTimeMilliseconds(stageBeginTimeStr);
	var stageCompletionTimeMillis = getTimeMilliseconds(stageCompletionTimeStr);
	if (validTimeMillis <= stageBeginTimeMillis) {
		// 若阶段开始时间大于等于有效期,则项目超时完成,有效时间为'-'
		return '-';
	}
	if (validTimeMillis > stageBeginTimeMillis && validTimeMillis < stageCompletionTimeMillis) {
		// 若有效期介于阶段完成时间和阶段开始时间之间,则该阶段按时完后时间(实际该阶段是超时完成的)即蓝色柱状图的终值为有效期
		return stageProgress.validTime;
	}
	if (validTimeMillis >= stageCompletionTimeMillis) {
		// 若有效期大于等于阶段完成时间,则阶段按时完成
		return stageCompletionTimeStr;
	}
}

/**
 * 获取阶段内的超时完成时间(红色色柱状图值)
 * @param {Object} stage 阶段
 * @param {Object} stateBeginTime
 * @param {Object} stateCompletionTime
 */
function getOverTimeCompletionTime(stage, stageBeginTimeStr, stageCompletionTimeStr) {
	var validTimeMillis = getTimeMilliseconds(stageProgress.validTime);
	var stageBeginTimeMillis = getTimeMilliseconds(stageBeginTimeStr);
	var stageCompletionTimeMillis = getTimeMilliseconds(stageCompletionTimeStr);

	if (validTimeMillis < stageCompletionTimeMillis) {
		// 阶段完成时间大于有效期,则将阶段完成时间作为超时时间返回
		return stageCompletionTimeStr;
	}

	if (validTimeMillis >= stageCompletionTimeMillis) {
		// 阶段完成时间小于等于有效期,则阶段按时完成,超时时间应为'-'
		return '-';
	}
}

/**
 * 根据时间字符串获取对应的毫秒值
 * @param {Object} timeStr 时间字符串
 */
function getTimeMilliseconds(timeStr) {
	return (new Date(timeStr)).getTime();
}

/**
 *获取时间坐标轴的起始和结束值
 */
function getProperTimeAxisBeginAndEndTime() {
	var xAxis = getXAxisData();
	var begin = xAxis[0];
	var end = xAxis[xAxis.length - 1];
	var beginDate = new Date(begin);
	var endDate = new Date(end);

	if (xAxisLabelUnit.month) {
		beginDate.setDate(1);
		endDate.setMonth(endDate.getMonth() + 1);
		endDate.setDate(1);
	} else {
		var daysCount = getProperTimeAxisInterval() / timeInterval.day;
		console.log("daysCount " + daysCount);
		beginDate.setDate(beginDate.getDate() - daysCount);
		endDate.setDate(endDate.getDate() + daysCount);
	}
	var beArr = [formatDateToStr(beginDate), formatDateToStr(endDate)];
	console.log("beArr " + beArr);
	return beArr;
}

四.后续需求修改1

最近需求有修改,需要在 “方案”、“纲要”和“成果” 三个阶段的每个阶段都显示两个柱条,来显示对应阶段的实际用时和计划用时,在每个阶段中,实际用时柱条在上,计划用时柱条在下,突出对比效果,并且实际用时的柱条要可以显示蓝色(计划内用时)和红色(超时用时)两种颜色柱条的堆叠,同时移除之前的贯穿三个阶段的 “计划开始时间”和“有效期” markLine,最后修改成这个效果

这里需要修改的地方主要有:

  1. 去掉 markLine 配置。
  2. 在 series 中,新增另一个 stack: '计划'来显示计划用时柱条。
  3. 同时由于在一个阶段中已经有了一个新柱条可以显示某一阶段的计划完成时间,所以在实际完成时间的柱条上,不再需要显示计划完成时间了(对应之前进度图中的有效期),防止文字挤压。
  4. getProperTimeAxisInterval() 方法中 在总的时间跨度 <=3 时,return timeInterval.day * 5,这样横坐标显示的坐标数量可以多些。
  5. getOnTimeCompletionTime() 和 getOverTimeCompletionTime() 中新添加一个参数 stagePlanCompletionTimeStr 阶段计划完成时间,同时方法内的对比使用参数,不再使用之前的 “有效时间” 来对比。

具体js代码如下:

/**
 * 存储阶段进度时间段的对象
 */
var stageProgress = {
	// 			// 方案 实际时间段
	// 			fangAnTimeBucket: ['2017-01-01', '2017-03-01'],
	// 			// 纲要 实际时间段
	// 			gangYaoTimeBucket: ['2017-02-26', '2017-08-29'],
	// 			// 成果 实际时间段
	// 			chengGuoTimeBucket: ['2017-08-29', '2017-12-06'],
	// 			// 计划开始时间
	// 			scheduledStartTime: '2016-12-22',
	// 			// 有效截止日期
	// 			validTime: '2017-05-10'

	// 		// 方案 实际时间段
	// 		fangAnTimeBucket: ['2017-01-01', '2017-01-20'],
	// 		// 纲要 实际时间段
	// 		gangYaoTimeBucket: ['2017-02-07', '2017-02-28'],
	// 		// 成果 实际时间段
	// 		chengGuoTimeBucket: ['2017-02-15', '2017-03-20'],
	// 		// 计划开始时间
	// 		scheduledStartTime: '2017-01-01',
	// 		// 有效截止日期
	// 		validTime: '2017-02-20'

//	// 方案 实际时间段
//	fangAnTimeBucket: ['2017-11-23', '2017-11-30'],
//	// 方案 计划时间段
//	fangAnPlanTimeBucket: ['2017-11-20', '2017-11-26'],
//
//	// 纲要 实际时间段
//	gangYaoTimeBucket: ['2017-11-25', '2017-12-03'],
//	// 纲要 计划时间段
//	gangYaoPlanTimeBucket: ['2017-11-25', '2017-12-03'],
//
//	// 成果 实际时间段
//	chengGuoTimeBucket: ['2017-12-06', '2017-12-12'],
//	// 成果 计划时间段
//	chengGuoPlanTimeBucket: ['2017-12-07', '2017-12-14']
	
	// 方案 实际时间段
	fangAnTimeBucket: ['2019-02-04', '2019-02-27'],
	// 方案 计划时间段
	fangAnPlanTimeBucket: ['2019-02-05', '2019-02-26'],

	// 纲要 实际时间段
	gangYaoTimeBucket: ['-', '-'],
	// 纲要 计划时间段
	gangYaoPlanTimeBucket: ['2019-02-27', '2019-03-07'],

	// 成果 实际时间段
	chengGuoTimeBucket: ['-', '-'],
	// 成果 计划时间段
	chengGuoPlanTimeBucket: ['2019-03-08', '2019-04-17']
	
	//	// 计划开始时间
	//	scheduledStartTime: '2017-11-23',
	//	// 有效截止日期
	//	validTime: '2017-11-30 '
};

/**
 * 横坐标轴时间刻度可选值
 * 这里 month和year 没有考虑平闰年之分
 */
var timeInterval = {
	day: 3600 * 1000 * 24,
	month: 3600 * 1000 * 24 * 31,
	year: 3600 * 1000 * 24 * 31 * 12,
};

/**
 * 时间坐标轴标签单位应该精确到哪一位
 */
var xAxisLabelUnit = {
	year: false,
	month: false,
	day: false
}

/**
 * 获取合适的横坐标时间刻度间隔
 */
function getProperTimeAxisInterval() {
	xAxisLabelUnit.year = false;
	xAxisLabelUnit.month = false;
	xAxisLabelUnit.day = false;

	var timeDataArray = getXAxisData();
	var begin = getTimeMilliseconds(timeDataArray[timeDataArray.length - 1]);
	console.log("begin " + begin);
	var periodMillis = getTimeMilliseconds(timeDataArray[timeDataArray.length - 1]) - getTimeMilliseconds(timeDataArray[0]);
	console.log("periodMillis " + periodMillis);
	var years = periodMillis / timeInterval.year;
	console.log("years " + years);
	var months = periodMillis / timeInterval.month;
	console.log("months " + months);
	var days = periodMillis / timeInterval.day;
	console.log("days " + days);

	if(months <= 3) {
		xAxisLabelUnit.day = true;
		return timeInterval.day * 5;
	} else if(months <= 16) {
		xAxisLabelUnit.month = true;
		return timeInterval.month;
	} else if(months <= 24) {
		xAxisLabelUnit.month = true;
		return timeInterval.month * 2;
	} else if(years <= 16) {
		xAxisLabelUnit.year = true;
		return timeInterval.year;
	}
}

/**
 * 获取横轴坐标数据源,这里横坐标只显示年月
 * 最小值取传入数据最小的时间再减小一个月
 * 最大值取传入数据最小的时间再增加一个月
 */
function getXAxisData() {
	var arr = new Array();
	arr = arr
		//	.concat(stageProgress.scheduledStartTime)
		.concat(stageProgress.fangAnTimeBucket)
		.concat(stageProgress.fangAnPlanTimeBucket)
		.concat(stageProgress.gangYaoTimeBucket)
		.concat(stageProgress.gangYaoPlanTimeBucket)
		.concat(stageProgress.chengGuoTimeBucket)
		.concat(stageProgress.chengGuoPlanTimeBucket)
		//		.concat(stageProgress.validTime)
		.filter(function(item) {
			return item != "-";
		}).sort();
	console.log(arr);
	return arr;
}

/**
 * 更改日期字符串为相应月份的第一天
 * @param {Object} dateStr 日期字符串
 */
function changeDateToMonthFirstDay(dateStr) {
	var inputDate = new Date(dateStr);
	inputDate.setDate(1);
	var result = inputDate.getFullYear() + "-" +
		(inputDate.getMonth() >= 9 ? inputDate.getMonth() + 1 : "0" +
			(inputDate.getMonth() + 1)) + "-" + ("0" + 1);
	return result;
}

/**
 * 获取格式化的日期 YYYY-MM-dd
 */
function formatDateToStr(date) {
	var inputMonth = date.getMonth();
	var inputDate = date.getDate();
	var result = date.getFullYear() +
		"-" + (inputMonth >= 9 ? inputMonth + 1 : "0" + (inputMonth + 1)) +
		"-" + (inputDate >= 9 ? inputDate : "0" + (inputDate));
	return result;
}

var faOnTimeCompletionTime = getOnTimeCompletionTime('方案', stageProgress.fangAnTimeBucket[0], stageProgress.fangAnTimeBucket[
	1], stageProgress.fangAnPlanTimeBucket[1]);
var gyOnTimeCompletionTime = getOnTimeCompletionTime('纲要', stageProgress.gangYaoTimeBucket[0], stageProgress.gangYaoTimeBucket[
	1], stageProgress.gangYaoPlanTimeBucket[1]);
var cgOnTimeCompletionTime = getOnTimeCompletionTime('成果', stageProgress.chengGuoTimeBucket[0], stageProgress.chengGuoTimeBucket[
	1], stageProgress.chengGuoPlanTimeBucket[1]);

var faOverTimeCompletionTime = getOverTimeCompletionTime('方案', stageProgress.fangAnTimeBucket[0], stageProgress.fangAnTimeBucket[
	1], stageProgress.fangAnPlanTimeBucket[1]);
var gyOverTimeCompletionTime = getOverTimeCompletionTime('纲要', stageProgress.gangYaoTimeBucket[0], stageProgress.gangYaoTimeBucket[
	1], stageProgress.gangYaoPlanTimeBucket[1]);
var cgOverTimeCompletionTime = getOverTimeCompletionTime('成果', stageProgress.chengGuoTimeBucket[0], stageProgress.chengGuoTimeBucket[
	1], stageProgress.chengGuoPlanTimeBucket[1]);

/**
 * 时间数组
 */
var timeArray = {
	// 实际开始时间
	beginTimeArr: [
		getTimeMilliseconds(stageProgress.fangAnTimeBucket[0]),
		getTimeMilliseconds(stageProgress.gangYaoTimeBucket[0]),
		getTimeMilliseconds(stageProgress.chengGuoTimeBucket[0]),
	],
	// 按时完成时间
	onTimeCompletionTimeArr: [
		getTimeMilliseconds(faOnTimeCompletionTime),
		getTimeMilliseconds(gyOnTimeCompletionTime),
		getTimeMilliseconds(cgOnTimeCompletionTime),
	],
	// 超时完成时间
	overTimeCompletionTimeArr: [
		getTimeMilliseconds(faOverTimeCompletionTime),
		getTimeMilliseconds(gyOverTimeCompletionTime),
		getTimeMilliseconds(cgOverTimeCompletionTime),
	],

	// 计划开始时间
	planbeginTimeArr: [
		getTimeMilliseconds(stageProgress.fangAnPlanTimeBucket[0]),
		getTimeMilliseconds(stageProgress.gangYaoPlanTimeBucket[0]),
		getTimeMilliseconds(stageProgress.chengGuoPlanTimeBucket[0]),
	],
	planCompletionTimeArr: [
		getTimeMilliseconds(stageProgress.fangAnPlanTimeBucket[1]),
		getTimeMilliseconds(stageProgress.gangYaoPlanTimeBucket[1]),
		getTimeMilliseconds(stageProgress.chengGuoPlanTimeBucket[1]),
	],
};

// 初始化图表
var myChart = echarts.init(document.getElementById('content'));

// 构建图表配置项
option = {
	tooltip: {
		trigger: 'axis',
		axisPointer: {
			type: 'shadow',
		},

		/**
		 * 也可以使用 formatter: '{b0}:<br />{a0}: {c0}<br />{a1}: {c1}<br />{a2}: {c2}',
		 * 但是这样当鼠标指向纵坐标的三个阶段中的某一个时,即使该阶段 没有按时完成,或者 没有超时,
		 * 也会显示 按时 或 超时 的 tooltip
		 */
		formatter: function(params) {
			console.log("params" + JSON.stringify(params));
//			 			console.log("params[0]" + JSON.stringify(params[0]));
//			 			console.log("params[1]" + JSON.stringify(params[1]));
//			console.log("params[2]" + JSON.stringify(params[2]));
			var info = params[0].axisValue + ":<br />";
			info += params[0].seriesName + ":" + getSeriesDateStr(params[0].data) + "<br />";
			info += "结束时间:" + (params[2].data != "-" && params[2].data != undefined && params[2].data != null && !isNaN(params[2].data) ?
				getSeriesDateStr(params[2].data) :
				(!isNaN(params[1].data) && params[1].data != undefined && params[1].data != null ? getSeriesDateStr(params[1].data) : "-"))
			+ "<br />";
			info+=params[3].seriesName+":"+getSeriesDateStr(params[3].data) + "<br />";
			info+="计划完成时间:"+getSeriesDateStr(params[4].data);
			return info;
		},
	},

	/**
	 * 右上角工具栏
	 */
	toolbox: {
		right: '3%',
		show: false,
		feature: {
			/**
			 * 数据视图
			 */
			// 			dataView:{
			// 				show:true,
			// 			},
			saveAsImage: {
				show: true
			}
		}
	},

	/**
	 * 图例
	 */
	legend: {
		data: ['按时', '超时', '计划'],
		tooltip: {
			show: true,
		},
		itemWidth: 15,
		itemHeight: 15,
		textStyle: {
			fontSize: 16,
			fontFamily: 'Microsoft YaHei',
		},
		itemGap: 80,
		padding: 10,
	},

	/**
	 * 直角坐标系内绘图网格
	 */
	grid: {
		left: '3%',
		right: '3%',
		bottom: '2%',
		/**
		 * grid 区域是否包含坐标轴的刻度标签。
		 */
		containLabel: true,
	},

	/**
	 * 横坐标
	 */
	xAxis: {
		// 		/**
		// 		 * 坐标轴指示器
		// 		 */
		// 		axisPointer: {
		// 			show: true,
		// 		},

		/**
		 * 时间轴,适用于连续的时序数据,与数值轴相比时间轴带有时间的格式化,
		 * 在刻度计算上也有所不同,例如会根据跨度的范围来决定使用月,星期,日还是小时范围的刻度。
		 */
		type: 'time',

		/**
		 * value 是一个包含 min 和 max 的对象,分别表示数据的最大最小值,这个函数应该返回坐标轴的最大值。
		 *
		 * 坐标轴刻度最小值。
		 */
		min: function(value) {
			return value.min + (getTimeMilliseconds(getProperTimeAxisBeginAndEndTime()[0]) - value.min);
		},

		/**
		 * value 是一个包含 min 和 max 的对象,分别表示数据的最大最小值,这个函数应该返回坐标轴的最大值。
		 *
		 * 坐标轴刻度最大值。
		 */
		max: function(value) {
			return value.max + (getTimeMilliseconds(getProperTimeAxisBeginAndEndTime()[1]) - value.max);
		},
		// 
		/**
		 * 设置坐标轴分割间隔
		 */
		interval: getProperTimeAxisInterval(),
		axisLine: {
			lineStyle: {
				color: '#BDC8CD',
				width: 1,
			},
		},

		/**
		 * 坐标轴刻度标签的相关设置。
		 */
		axisLabel: {
			showMinLabel: false,
			showMaxLabel: false,
			rotate: 35,
			margin: 12,
			fontSize: 16,
			color: '#1c2431',
			formatter: function(value, index) {
				var date = new Date(value);
				// var time = date.getFullYear() + "." + (date.getMonth() + 1) + "." + date.getDate();
				var time = date.getFullYear();
				if(xAxisLabelUnit.month) {
					time += "." + (date.getMonth() + 1);
				}
				if(xAxisLabelUnit.day) {
					time += "." + (date.getMonth() + 1) + '.' + date.getDate();
				}
				return time;
			},
		},

		/**
		 * 坐标轴刻度分割线
		 */
		splitLine: {
			show: false,
		},
	},

	/**
	 * 纵坐标
	 */
	yAxis: {
		type: 'category',
		data: ['方\n案', '纲\n要', '成\n果'],
		axisTick: {
			show: false,
		},
		axisLine: {
			lineStyle: {
				color: '#e9e9ea',
				width: 1,
			},
		},
		axisLabel: {
			fontWeight: 'bold',
			fontSize: 16,
			color: '#1c2431',
			fontFamily: 'Microsoft YaHei',
		},
		splitLine: {
			show: true,
			lineStyle: {
				color: '#eaeae9',
				width: 1,
			},
		},
	},

	/**
	 * 系列
	 */
	series: [{
			name: '开始时间',
			type: 'bar',
			stack: '时间',
			itemStyle: {
				normal: {
					barBorderColor: 'rgba(0,0,0,0)',
					color: 'rgba(0,0,0,0)'
				},
				emphasis: {
					barBorderColor: 'rgba(0,0,0,0)',
					color: 'rgba(0,0,0,0)'
				}
			},
			label: {
				normal: {
					formatter: function(params) {
						return getSeriesDateStr(params.value);
					},
					show: true,
					position: 'insideRight',
					fontSize: 16,
					color: '#1c2431',
					fontFamily: 'Microsoft YaHei',
					offset: [40, -15],
				}
			},
			data: timeArray.beginTimeArr,
			/**
			 * 柱状图宽度
			 */
			barWidth: 15,
			barCategoryGap:'50%',
		}, {
			name: '按时',
			type: 'bar',
			stack: '时间',
			itemStyle: {
				normal: {
					color: '#0f77e9'
				}
			},
			label: {
				normal: {
					formatter: function(params) {
						return getSeriesDateStr(params.value);
					},
					show: false,
					fontSize: 16,
					color: '#1c2431',
					position: 'right',
					fontFamily: 'Microsoft YaHei',
					offset: [-45, -15],
				}
			},
			data: timeArray.onTimeCompletionTimeArr,
			/**
			 * 柱状图宽度
			 */
			barWidth: 15,
			barCategoryGap:'50%',
		}, {
			name: '超时',
			type: 'bar',
			stack: '时间',
			itemStyle: {
				normal: {
					color: '#ff4747'
				}
			},
			label: {
				normal: {
					formatter: function(params) {
						return getSeriesDateStr(params.value);
					},
					show: true,
					fontSize: 16,
					color: '#1c2431',
					offset: [-45, -15],
					position: 'right',
					fontFamily: 'Microsoft YaHei',
				}
			},
			data: timeArray.overTimeCompletionTimeArr,
			/**
			 * 柱状图宽度
			 */
			barWidth: 15,
			barCategoryGap:'50%',
		},
		{
			name: '计划开始时间',
			type: 'bar',
			stack: '计划',
			itemStyle: {
				normal: {
					barBorderColor: 'rgba(0,0,0,0)',
					color: 'rgba(0,0,0,0)'
				},
				emphasis: {
					barBorderColor: 'rgba(0,0,0,0)',
					color: 'rgba(0,0,0,0)'
				}
			},
			label: {
				normal: {
					formatter: function(params) {
						return getSeriesDateStr(params.value);
					},
					show: true,
					position: 'insideRight',
					fontSize: 16,
					color: '#1c2431',
					fontFamily: 'Microsoft YaHei',
					//					offset: [40, -20],
					offset: [45, 18],
				}
			},
			data: timeArray.planbeginTimeArr,
			/**
			 * 柱状图宽度
			 */
			barWidth: 15,
			barCategoryGap:'50%',
		},
		{
			name: '计划',
			type: 'bar',
			stack: '计划',
			itemStyle: {
				normal: {
					color: '#FFA500'
				}
			},
			label: {
				normal: {
					formatter: function(params) {
						return getSeriesDateStr(params.value);
					},
					show: true,
					fontSize: 16,
					color: '#1c2431',
					//					offset: [-45, -20],
					position: 'right',
					offset: [-45, 18],
					fontFamily: 'Microsoft YaHei',
				}
			},
			data: timeArray.planCompletionTimeArr,
			/**
			 * 柱状图宽度
			 */
			barWidth: 15,
			barCategoryGap:'50%',
		}
	]
};

// 将构建好的配置项传入echarts
myChart.setOption(option);

/**
 * 时间对象转日期字符串 yyyy.MM.dd
 * @param {Object} timeObject 毫秒值或时间字符串
 */
function getSeriesDateStr(timeObject) {
	if(timeObject == "-") {
		return timeObject;
	}
	var date = new Date(timeObject);
	var dateStr = '';
	dateStr += date.getFullYear() + '.';
	dateStr += date.getMonth() + 1 + '.';
	dateStr += date.getDate();
	return dateStr;
};

/**
 * 获取阶段的计划内完成时间(蓝色柱状图值)
 * @param {Object} stage 阶段
 * @param {Object} stateBeginTime 阶段实际开始时间
 * @param {Object} stateCompletionTime 阶段实际完成时间
 * @param{Object} stagePlanCompletionTimeStr 阶段计划完成时间
 */
function getOnTimeCompletionTime(stage, stageBeginTimeStr, stageCompletionTimeStr, stagePlanCompletionTimeStr) {
	//	var validTimeMillis = getTimeMilliseconds(stageProgress.validTime);
	var validTimeMillis = getTimeMilliseconds(stagePlanCompletionTimeStr);
	var stageBeginTimeMillis = getTimeMilliseconds(stageBeginTimeStr);
	var stageCompletionTimeMillis = getTimeMilliseconds(stageCompletionTimeStr);
	if(validTimeMillis <= stageBeginTimeMillis) {
		// 若阶段开始时间大于等于有效期,则项目超时完成,有效时间为'-'
		return '-';
	}
	if(validTimeMillis > stageBeginTimeMillis && validTimeMillis < stageCompletionTimeMillis) {
		// 若有效期介于阶段完成时间和阶段开始时间之间,则该阶段按时完后时间(实际该阶段是超时完成的)即蓝色柱状图的终值为有效期
		return stagePlanCompletionTimeStr;
	}
	if(validTimeMillis >= stageCompletionTimeMillis) {
		// 若有效期大于等于阶段完成时间,则阶段按时完成
		return stageCompletionTimeStr;
	}
}

/**
 * 获取阶段内的超时完成时间(红色色柱状图值)
 * @param {Object} stage 阶段
 * @param {Object} stateBeginTime
 * @param {Object} stateCompletionTime
 * @param{Object} stagePlanCompletionTimeStr 阶段计划完成时间
 */
function getOverTimeCompletionTime(stage, stageBeginTimeStr, stageCompletionTimeStr, stagePlanCompletionTimeStr) {
	//	var validTimeMillis = getTimeMilliseconds(stageProgress.validTime);
	var validTimeMillis = getTimeMilliseconds(stagePlanCompletionTimeStr);
	var stageBeginTimeMillis = getTimeMilliseconds(stageBeginTimeStr);
	var stageCompletionTimeMillis = getTimeMilliseconds(stageCompletionTimeStr);

	if(validTimeMillis < stageCompletionTimeMillis) {
		// 阶段完成时间大于有效期,则将阶段完成时间作为超时时间返回
		return stageCompletionTimeStr;
	}

	if(validTimeMillis >= stageCompletionTimeMillis) {
		// 阶段完成时间小于等于有效期,则阶段按时完成,超时时间应为'-'
		return '-';
	}
}

/**
 * 根据时间字符串获取对应的毫秒值
 * @param {Object} timeStr 时间字符串
 */
function getTimeMilliseconds(timeStr) {
	return(new Date(timeStr)).getTime();
}

/**
 *获取时间坐标轴的起始和结束值
 */
function getProperTimeAxisBeginAndEndTime() {
	var xAxis = getXAxisData();
	var begin = xAxis[0];
	var end = xAxis[xAxis.length - 1];
	var beginDate = new Date(begin);
	var endDate = new Date(end);

	if(xAxisLabelUnit.month) {
		beginDate.setDate(1);
		endDate.setMonth(endDate.getMonth() + 1);
		endDate.setDate(1);
	} else {
		var daysCount = getProperTimeAxisInterval() / timeInterval.day;
		console.log("daysCount " + daysCount);
		beginDate.setDate(beginDate.getDate() - daysCount);
		endDate.setDate(endDate.getDate() + daysCount);
	}
	var beArr = [formatDateToStr(beginDate), formatDateToStr(endDate)];
	console.log("beArr " + beArr);
	return beArr;
}

  • 24
    点赞
  • 125
    收藏
    觉得还不错? 一键收藏
  • 12
    评论
实现多层甘特图可以通过使用echarts的gantt组件,结合vue的组件化实现。 首先,在vue项目中安装echarts: ``` npm install echarts --save ``` 然后,在vue组件中引入echarts: ```javascript import echarts from 'echarts' ``` 接着,在vue组件中定义一个gantt组件,并在其中使用echarts: ```vue <template> <div class="gantt-chart"></div> </template> <script> export default { name: 'GanttChart', props: { data: { type: Array, required: true }, height: { type: String, default: '500px' } }, mounted() { this.initChart() }, methods: { initChart() { const chart = echarts.init(document.querySelector('.gantt-chart')) chart.setOption({ tooltip: { formatter: function (params) { return params.marker + params.name + ': ' + new Date(params.start).toLocaleDateString() + ' - ' + new Date(params.end).toLocaleDateString() } }, grid: { top: 10, left: 100, bottom: 30, right: 30 }, xAxis: { min: new Date(2021, 0, 1), max: new Date(2022, 0, 1) }, yAxis: { type: 'category', data: ['Layer 1', 'Layer 2', 'Layer 3'] }, series: [{ type: 'gantt', data: this.data, barWidth: 20, label: { show: true, formatter: function (params) { return params.name } } }] }) } } } </script> ``` 在上述代码中,我们定义了一个名为GanttChart的vue组件,该组件接收两个props:data和height,其中data是一个数组,包含了多层甘特图的数据,height是图表的高度,默认为500px。 在mounted方法中,我们调用了initChart方法,该方法使用echarts.init初始化了一个echarts实例,并使用setOption方法设置了图表的各种配置项,包括tooltip、grid、xAxis、yAxis和series。 其中,series中的type属性设置为gantt,表示使用gantt组件,data属性设置为props中传入的data数组,barWidth属性设置为20,表示一个甘特图的宽度为20像素,label的formatter属性设置为params.name,表示在每个甘特图上显示其名称。 最后,在vue组件中使用该组件: ```vue <template> <div class="gantt-chart-wrapper"> <gantt-chart :data="ganttData" :height="'600px'" /> </div> </template> <script> import GanttChart from './GanttChart.vue' export default { name: 'App', components: { GanttChart }, data() { return { ganttData: [ { name: 'Task 1', start: new Date(2021, 0, 1), end: new Date(2021, 3, 1), y: 0 }, { name: 'Task 2', start: new Date(2021, 3, 1), end: new Date(2021, 6, 1), y: 0 }, { name: 'Task 3', start: new Date(2021, 6, 1), end: new Date(2021, 9, 1), y: 1 }, { name: 'Task 4', start: new Date(2021, 9, 1), end: new Date(2022, 0, 1), y: 2 } ] } } } </script> ``` 在上述代码中,我们在App组件中使用GanttChart组件,并传入了ganttData作为props的data属性,该数组包含了4个甘特图,分别属于三个不同的层级。其中,y属性表示当前甘特图所属的层级,从0开始计数。我们还为GanttChart组件设置了一个高度为600px。 通过上述代码,我们就可以在vue中实现多层甘特图了。
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值