先看效果,还有一些细节没处理
任务时间线效果图
本篇文章 使用 vue + day.js
// day.js 文档
https://dayjs.fenxianglu.cn/category/
1.任务状态有5个
- 任务响应
- 实时反馈
- 任务挂起
- 任务结束
- 任务中止
2.后端会返回当前任务主任务信息和子任务信息
// 数据格式大概是这样
// 很明显我们需要显示 标题 和 timeline包含的数据点位信息,然后后续,根据点击后得到的id,在从后端获取详情数据
[{
name: "区域挖掘救援4",
id: 962,
timeline: [
{ date: "2023-07-04 15:20", id: 21221, state: "任务响应" },
{ date: "2023-07-04 17:50", id: 186254, state: "实时反馈" },
],
children: [
{
name: "务4-1",
id: 6235,
timeline: [
{ date: "2023-07-04 17:12", id: 36511, state: "任务响应" },
],
children: [
{
name: "区域挖区域挖掘救援2掘救援4-1-1",
id: 154123,
timeline: [
{ date: "2023-07-04 15:20", id: 19991, state: "任务响应" },
{ date: "2023-07-04 17:50", id: 16661, state: "实时反馈" },
],
children: [
{
name: "务4-1-1-1",
id: 8222,
timeline: [
{
date: "2023-07-04 17:12",
id: 77741,
state: "任务响应",
},
],
},
],
},
],
},
],
},
]
3.分析
1.怎让上方的时间轴获取最小时间和最大时间
// arayFlat方法
/**
* @method arrayFlat 嵌套数组拍平
* @param {Array} data 源数据
* @param {String} [childrenKey='children'] 嵌套的子数据key
* @param {String} [itemType=''] 需要的itemkey
* @returns {Array} 返回处理好的数据
* @Date 2023-06-30 14:23:52
**/
export const arrayFlat = (data, childrenKey = "children", itemkey = "") => {
const result = [];
const filterItem = (item) => {
let dataType = Object.prototype.toString.call(item[itemkey]).slice(8, -1);
dataType == "Array"
? item[itemkey].forEach((r) => result.push(r))
: result.push(item[itemkey]);
};
data.forEach((item) => {
itemkey ? filterItem(item) : result.push(item);
if (item[childrenKey] && item[childrenKey].length > 0) {
result.push(...arrayFlat(item[childrenKey], childrenKey, itemkey));
}
});
return result;
};
// arrayFlat 处理结果
[
{
"date": "2023-07-04 14:08:00",
"id": 11,
"state": "任务响应"
},
{
"date": "2023-07-04 15:32:00",
"id": 12,
"state": "实时反馈"
},
{
"date": "2023-07-04 15:46:00",
"id": 1223,
"state": "任务挂起"
},
{
"date": "2023-07-04 15:56:00",
"id": 11232,
"state": "任务结束"
},
{
"date": "2023-07-04 17:56:00",
"id": 14232,
"state": "任务中止"
},
{
"date": "2023-07-04 15:40:00",
"id": 111,
"state": "任务响应"
},
{
"date": "2023-07-04 14:50:00",
"id": 112,
"state": "任务响应"
},
{
"date": "2023-07-04 15:20",
"id": 221,
"state": "任务响应"
},
{
"date": "2023-07-04 15:50",
"id": 222,
"state": "实时反馈"
},
{
"date": "2023-07-04 15:10",
"id": 1231,
"state": "任务响应"
},
{
"date": "2023-07-04 15:20",
"id": 32411,
"state": "任务响应"
},
{
"date": "2023-07-04 17:50",
"id": 1231,
"state": "实时反馈"
},
{
"date": "2023-07-04 17:12",
"id": 321,
"state": "任务响应"
},
{
"date": "2023-07-04 15:20",
"id": 21221,
"state": "任务响应"
},
{
"date": "2023-07-04 17:50",
"id": 186254,
"state": "实时反馈"
},
{
"date": "2023-07-04 17:12",
"id": 36511,
"state": "任务响应"
},
{
"date": "2023-07-04 15:20",
"id": 19991,
"state": "任务响应"
},
{
"date": "2023-07-04 17:50",
"id": 16661,
"state": "实时反馈"
},
{
"date": "2023-07-04 17:12",
"id": 77741,
"state": "任务响应"
}
]
// 获取数据的事件 最大时间和最小时间
getTableMinAndMaxTime(tableData) {
let minTimeValue = dayjs().format("YYYY-MM-DD HH:mm:ss");
const maxTime = (date) =>
dayjs(date).add(5, "hour").format("YYYY-MM-DD HH:mm:ss");
const isBeforeTime = (minTime, newTime) =>
dayjs(minTime).isBefore(dayjs(newTime));
const getTime = (item) => dayjs(item.date).unix();
let copyData = JSON.parse(JSON.stringify(tableData));
let newcopyAndFlatData = arrayFlat(copyData, "children", "timeline");
console.log('newcopyAndFlatData',newcopyAndFlatData);
newcopyAndFlatData.sort(
(befor, after) => getTime(befor) - getTime(after)
);
minTimeValue = !isBeforeTime(minTimeValue, newcopyAndFlatData[0].date)
? newcopyAndFlatData[0].date
: minTimeValue;
let maxTimeValue = maxTime(
newcopyAndFlatData[newcopyAndFlatData.length - 1].date
);
this.getHour(minTimeValue, maxTimeValue);
},
// 获取实际时间线数据 (最大和最小时间都要是整数方便计算)
getHour(minTime, maxTime) {
// 初始化当前时间为开始时间
let maxT = this.setMaxHours(maxTime);
let currentDateTime = this.setMinHours(minTime);
this.minTime = dayjs(currentDateTime).format("YYYY-MM-DD HH:mm:ss");
this.maxTime = dayjs(maxT).format("YYYY-MM-DD HH:mm:ss");
this.tiemDeff(currentDateTime, maxT);
const result = [];
while (currentDateTime.isBefore(maxT) || currentDateTime.isSame(maxT)) {
result.push({
date: currentDateTime.format("YYYY-MM-DD HH:mm"),
value: currentDateTime.format("HH:mm"),
});
currentDateTime = currentDateTime.add(10, "minute"); // 每次加10分钟
}
this.timeArr = result;
this.hoursDiff = result.length;
//console.log("最小时间~最大时间", result);
//console.log("最大移动次数", result.length);
},
// 考虑到不能让最小或者最大时间贴边显示这样不是很好看,所以每个时间±1小时处理
// 最大时间取整 +1小时 这里和当前时间最下比较,因为任务可能是当前时间往后推30分钟执行,也有可能没有最新任务比当前时间大
setTime() {
// 当前时间分钟向下取整
let minutesToAdd = dayjs().minute() % 10;
let fullCurrenTime = dayjs()
.subtract(minutesToAdd, "minute")
.format("YYYY-MM-DD HH:mm");
console.log("fullCurrenTime", fullCurrenTime);
this.fullCurrenTime = fullCurrenTime;
},
setMaxHours(dateTimeString) {
// 当前时间
const currentTiem = dayjs(this.fullCurrenTime);
// 时间线的最大时间
const timeLIneMaxTime = dayjs(dateTimeString);
// 是否在之后
let isAfter = currentTiem.isAfter(timeLIneMaxTime);
// 赋值最大时间
let maxTime = isAfter ? this.fullCurrenTime : dateTimeString;
const minutesToAdd = 10 - (dayjs(maxTime).minute() % 10);
const roundedDate = dayjs(maxTime).add(minutesToAdd, "minute");
return roundedDate;
},
// 最小时间取整 -1小时
setMinHours(dateTimeString) {
const minutesToAdd = dayjs(dateTimeString).minute() % 10;
const roundedDate = dayjs(dateTimeString)
.subtract(minutesToAdd, "minute")
.subtract(1, "hours");
return roundedDate;
},
2.时间问题处理完毕了,接下来就是把处理好的数据递渲染出来,这里需要用到递归组件
需要用到的处理
/**
* @method getTaskPositon 计算当前事件在事件线的百分比位置
* @param {Number} maxTime 最大事件
* @param {Number} minTime 最小事件
* @param {String} taskItem 当前事件
* @returns {Number} 距离左侧定位的 百分比数值
* @Date 2023-07-03 09:53:39
**/
export const getTaskPositon = (minTime, maxTime, taskItem) => {
let leftDistancePx = 0;
// 把时间都转换成dayjs
const date1 = dayjs(maxTime);
const date2 = dayjs(minTime);
const date3 = dayjs(taskItem);
// 得出俩个时间的 差异总分钟值
const diffMinute = date1.diff(date2, "minute");
// 取出当前事件到起始事件(最小事件) 分钟值
const taskItemMinute = date3.diff(date2, "minute");
// 事件线总分钟 / 事件从起点到自己的时间差值 = 当前事件应该所在的百分比位置
leftDistancePx = taskItemMinute / diffMinute;
return leftDistancePx;
};
export const timeDiff = (minTime, maxTime) => {
const date1 = dayjs(maxTime);
const date2 = dayjs(minTime);
// 得出俩个时间的 差异总分钟值
const diffMinute = date1.diff(date2, "minute");
let lineWidth = ((diffMinute + 10) * 1.4).toFixed(0);
return Number(lineWidth);
};
1.父组件
<template>
<div class="tree_container">
<tree-item
v-for="item in innerOptions"
:key="item.id"
:item="item"
:checkbox="checkbox"
:position="position"
:timeArr="timeArr"
:minTime="minTime"
:maxTime="maxTime"
></tree-item>
</div>
</template>
<script>
import TreeItem from "./tree-item.vue";
export default {
name: "tree",
data() {
return {
innerOptions: [], //进行处理成tree能用的数据
filterNode: "", // 过滤字段
};
},
props: {
// 表格数据
treeData: {
type: Array,
default: () => [],
},
// 是否选中
checkbox: {
type: Boolean,
default: false,
},
// 每次移动的数值
position: {
type: Number,
default: 0,
},
// 时间线数据
timeArr: {
type: Array,
default: () => [],
},
// 最小时间
minTime: {
type: String,
default: "",
},
// 最大时间
maxTime: {
type: String,
default: "",
},
},
components: {
TreeItem,
},
created() {
this.innerOptions = this.treeData.map((item) => this.handleData(item));
},
mounted() {},
methods: {
// 处理数据 变成tree需要的格式
handleData(item, indent = 0) {
return {
...item,
indent, // 缩进
expand: false, //是否展开
checked: false, //是否选中
disabled: false, //是否禁止选中
children: (item.children || []).map((children) =>
this.handleData(children, indent + 1)
),
};
},
},
computed: {},
beforeDestroy() {},
};
</script>
<style lang="scss" scoped>
.tree_container {
width: 955px;
max-height: 370px;
overflow: hidden;
overflow-y: scroll;
padding-right: 5px;
padding-top: 4px;
padding-bottom: 4px;
// 滚动条样式
&::-webkit-scrollbar-track {
background-color: #4175ac;
}
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-thumb {
border-radius: 5px;
border: 1px solid rgba(0, 174, 255, 0.604);
background: #69a9db;
}
}
</style>
2.子组件
<template>
<div class="treeItem_container">
<!-- 没有子元素 -->
<div class="row">
<!-- 左侧标题 -->
<div
:style="{ 'padding-left': indentLeft + 'px' }"
:class="[
'row-parent',
{ checkbox: item.checked },
item.indent == 0 ? 'levelMax' : 'levelmin',
item.expand && item.indent == 0 ? 'active' : 'deactive',
]"
@click.stop="handleClick(item)"
>
<!-- 标题 -->
<div class="title">
<div class="box"></div>
<div
class="img"
:class="[item.indent == 0 ? 'level-0' : 'level-1']"
></div>
<div class="text">{{ item.name }}</div>
</div>
<!-- 三角形展开收起 -->
<div
v-show="item.children.length > 0"
:class="[
item.children.length > 0 ? 'arrows' : '',
!item.expand ? 'close' : 'open'
]"
></div>
</div>
<div class="row-content">
<!-- 时间线 -->
<div
class="timeLine"
:style="{
transform: `translateX(${position}px)`,
width: `${lineWidth}px`,
}"
>
<div
class="row-content-time"
v-for="(timeArrItem, timeArrIndex) in timeArr"
:key="timeArrIndex"
@click="getTime(timeArrItem)"
></div>
</div>
<!-- 任务线 -->
<div
class="taskLine"
:style="{
transform: `translateX(${position}px)`,
width: `${lineWidth}px`,
}"
>
<div
:class="stateIcon[taskDate.state]"
:style="{
left: `${lineWidth * setItmePosition(taskDate.date)}px`,
}"
@click="getTime(taskDate)"
v-for="taskDate in item.timeline"
:key="taskDate.id"
></div>
</div>
</div>
</div>
<!-- 子元素,并且是展开的状态 -->
<div v-if="item.expand && item.children.length" class="item-children">
<tree-item
v-for="item in item.children"
:key="item.id"
:item="item"
:checkbox="checkbox"
:position="position"
:timeArr="timeArr"
:minTime="minTime"
:maxTime="maxTime"
></tree-item>
</div>
</div>
</template>
<script>
import { timeDiff, getTaskPositon } from "@/utils/common/index.js";
export default {
name: "tree-item",
data() {
return {
titleIcon: [
{
indent: 0,
style: "level-0",
},
{
indent: 1,
style: "level-1",
},
],
stateIcon: {
任务响应: "bluePoint",
实时反馈: "yellowPoint",
任务挂起: "greyPoint",
任务结束: "greendPoint",
任务中止: "redPoint",
},
lineWidth: 0,
};
},
props: {
item: {
type: Object,
default: () => ({
name: "",
indent: 0, // 缩进
expand: true, //是否展开
checked: true, //是否选中
disabled: false, //是否禁止选中
children: [],
}),
},
checkbox: {
type: Boolean,
default: false,
},
// 时间线数据
timeArr: {
type: Array,
default: () => [],
},
position: {
type: Number,
default: 0,
},
minTime: {
type: String,
default: "",
},
maxTime: {
type: String,
default: "",
},
//----下面还没扩展
// 手风琴模式
accordion: {
type: Boolean,
default: false,
},
// 节点过滤
"filter-node-method": {
type: String,
default: "",
},
},
created() {
this.setLineWidth();
},
methods: {
handleClick(item) {
console.log("handleItem", item);
const setChildrenExpand = (item) => {
item.expand = !item.expand;
if (item.children && item.children.length) {
setChildrenExpand(item.children);
}
};
// 判断子节点是否禁用
if (item.disabled) {
return;
}
// 判断该节点是否有children
if (item.children && item.children.length) {
setChildrenExpand(item);
} else {
// 处理item展开收起状态
item.expand = !item.expand;
}
},
setLineWidth() {
let lineWidth = timeDiff(this.minTime, this.maxTime);
this.lineWidth = lineWidth;
},
// 设置item的定位
setItmePosition(itemTime) {
return getTaskPositon(this.minTime, this.maxTime, itemTime);
},
getTime(data) {
if (data?.state) this.$emit("getTaskDate", data);
console.table([data]);
},
},
computed: {
indentLeft() {
return this.item.indent * 30;
},
},
beforeDestroy() {},
};
</script>
<style lang="scss" scoped>
.treeItem_container {
width: 955px;
max-height: 370px;
overflow: hidden;
overflow-y: scroll;
padding-right: 5px;
padding-top: 4px;
padding-bottom: 4px;
// 滚动条样式
&::-webkit-scrollbar-track {
background-color: #4175ac;
}
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-thumb {
border-radius: 5px;
border: 1px solid rgba(0, 174, 255, 0.604);
background: #69a9db;
}
.row {
display: flex;
border-radius: 32px;
width: 100%;
height: 54px;
margin-bottom: 10px;
// 左侧内容
.row-parent {
width: 211px;
height: 54px;
line-height: 54px;
font-size: 18px;
font-family: Microsoft YaHei;
font-weight: 400;
color: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
.title {
display: flex;
align-items: center;
overflow: hidden;
.box {
width: 18px;
}
.img {
width: 16px;
height: 18px;
text-align: center;
line-height: 18px;
}
.text {
/* 将超出的内容隐藏 */
overflow: hidden;
/* 禁止文字自动换行 */
white-space: nowrap;
/* 多余的文字显示为省略号 */
text-overflow: ellipsis;
}
}
.arrows {
width: 0px;
height: 0px;
margin-right: 9px;
border: 1px teal;
}
.open {
border-top: none;
border-right: 6px solid transparent;
border-bottom: 8px solid #ffffff;
border-left: 6px solid transparent;
}
.close {
border-top: 8px solid #ffffff;
border-right: 6px solid transparent;
border-bottom: none;
border-left: 6px solid transparent;
}
}
.deactive {
background: url("@/assets/img/commandModule/任务跟踪/默认.png")
no-repeat;
background-size: cover;
}
.active {
background: url("@/assets/img/commandModule/任务跟踪/选中.png")
no-repeat;
background-size: cover;
}
// 右侧内容
.row-content {
width: 714px;
height: 54px;
margin-left: 24px;
overflow: hidden;
position: relative;
// 任务线
.taskLine {
display: flex;
align-items: center;
position: absolute;
left: 0px;
top: 50%;
}
// 时间线
.timeLine {
display: flex;
align-items: center;
height: 100%;
// 时间线的小段
.row-content-time {
width: 10px;
height: 2px;
border-radius: 50%;
background-color: #6d8484c3;
margin: 0px 2px;
}
}
}
}
}
.level-0 {
width: 16px;
height: 18px;
margin-right: 20px;
background: url("/src/assets/img/commandModule/任务跟踪/任务图标.png") 0px 0px /
100% 100% no-repeat;
}
.level-1 {
width: 16px;
height: 18px;
margin-right: 20px;
background: none;
position: relative;
&::before {
content: "";
width: 10px;
height: 10px;
background: #00f2ff;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
.bluePoint {
width: 14px;
height: 14px;
border-radius: 50%;
background: url("/src/assets/img/commandModule/任务跟踪/1.png") center / 300%
300% no-repeat;
cursor: pointer;
display: inline-block;
position: absolute;
// top: 0px;
}
.yellowPoint {
width: 14px;
height: 14px;
border-radius: 50%;
background: url("/src/assets/img/commandModule/任务跟踪/2.png") center / 300%
300% no-repeat;
cursor: pointer;
position: absolute;
display: inline-block;
// top: 0px;
}
.greyPoint {
width: 14px;
height: 14px;
border-radius: 50%;
background: url("/src/assets/img/commandModule/任务跟踪/4.png") center / 300%
300% no-repeat;
cursor: pointer;
position: absolute;
// top: 0px;
}
.greendPoint {
width: 14px;
height: 14px;
border-radius: 50%;
background: url("/src/assets/img/commandModule/任务跟踪/3.png") center / 300%
300% no-repeat;
cursor: pointer;
position: absolute;
// top: 0px;
}
.redPoint {
width: 14px;
height: 14px;
border-radius: 50%;
background: url("/src/assets/img/commandModule/任务跟踪/5.png") center / 300%
300% no-repeat;
cursor: pointer;
position: absolute;
// top: 0px;
// background: radial-gradient(circle, #ff0000, #990000);
}
</style>
3.计算处理后的最小时间~最大时间可以点击的次数,计算出可点击移动的范围。这里后续可以优化
tiemDeff(minTime, maxTime) {
let min = dayjs(minTime);
let max = dayjs(maxTime);
let timeDiffHour = max.diff(min, "hour");
this.timeLinemMovingRange = timeDiffHour - 8; // 最大移动值
},
rightMoving() {
if(this.rightMovingRange == 0) return
this.leftMovingRange -= 1;
this.rightMovingRange -= 1;
this.position += this.moveNumber; // 每次点击增
},
leftMoving() {
if(this.rightMovingRange == this.timeLinemMovingRange) return
this.leftMovingRange += 1;
this.rightMovingRange += 1;
this.position -= this.moveNumber; // 每次点击增
},