目录
[1. 演示效果](#1. 演示效果)
[2.1 处理表头数据(日期对应的是星期几)](#2.1 处理表头数据(日期对应的是星期几))
[2.2 获取项目数据](#2.2 获取项目数据)
[2.3 合并单元格](#2.3 合并单元格)
0.背景
目遇到一个展示项目进度的需求,类似甘特图的效果,不同的是,每一个项目都有计划和实际两种,然后点击可以进行交互等。其实有很多可以拿来的甘特图组件,眼花缭乱的。但是因为我们需要自定义拓展,还是有些受限,干脆用table自己写了一个组件。
1. 演示效果
2.实现原理
选择两个日期,处理日期区间,渲染表头,获取数据后,根据不同的状态进行渲染即可。
用到的无非就是table的固定列和合并单元格。
2.1 处理表头数据(日期对应的是星期几)
用到了moment.js,根据所选择的日期区间,所处间隔天数,再根据日期进行当前日期是周几的运算,得出表头数据。如下图所示👇
java
getDaysWeek() {
this.dateRange = [];
const diffDays = moment(this.month[1]).diff(moment(this.month[0]), 'days') + 1;
this.tableWidth = 800 + 100 * diffDays;
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const dayOfWeek = moment(this.month[0]).day();
for (let i = 0; i < diffDays; i++) {
const day = moment(this.month[0]).add(i, 'days').format('YYYY-MM-DD');
const week = weekDays[(dayOfWeek + i) % 7]
this.dateRange.push({day: day, week: week})
}
}
2.2 获取项目数据
项目数据的格式如下,因为一个项目分了两条数据,所以两条数据为一组。如下所示
javascript
self.planData = [
{
startTime: '2024-10-02', // 开始时间
endTime: '2024-10-05', // 结束时间
entrustName: '一个大的项目名称', // 委托名称
planId: '1', // 项目id
planName: '测试项目1', // 项目名称
showName: '计划中', // 展示名称
status: 0 // 状态:0:计划;1:执行中;2:已完成
}, {
endTime: "2024-10-04",
entrustName: "一个大的项目名称",
planId: "1",
planName: "测试项目1",
showName: "赶工完成",
startTime: "2024-10-02",
status: 2
},{
endTime: '2024-10-15',
entrustName: '一个小的项目名称',
planId: '2',
planName: '测试项目2',
showName: '计划中',
startTime: '2024-10-09',
status: 0
}, {
endTime: "",
entrustName: "一个小的项目名称",
planId: "2",
planName: "测试项目2",
showName: "实际执行",
startTime: "2024-10-10",
status: 1
}
];
2.3 合并单元格
根据项目的开始结束日期范围,进行单元格合并。如下图红色标注所示
javascript
handleColspanData() {
const self = this;
let colIndex = 0;
self.mergeColumnsData = [];
self.planData.forEach((item, index) => {
colIndex = index % 2 === 0 ? 6 : 3;
self.dateRange.forEach((child, childIndex) => {
if (this.isBetweenCheck(item, child.day)) {
// 判断mergeColumnsData是否存在?存在则替换,反之插入
let id = index % 2 === 0 ? 'p-' + item.planId : 'r-' + item.planId;
let targetIndex = self.mergeColumnsData.findIndex(col => col.id === id);
if (targetIndex !== -1) {
self.mergeColumnsData[targetIndex].colCount = self.mergeColumnsData[targetIndex].colCount + 1
} else {
self.mergeColumnsData.push({
id: id,
rowIndex: index,
colIndex: colIndex + childIndex,
colCount: 1
});
}
}
})
});
self.mergeColumns('gantt-table', self.mergeColumnsData);
},
mergeColumns(tableId, mergeInfo) {
const table = document.getElementById(tableId);
if (!table) return;
// 获取tbody元素
const tbody = table.tBodies[0];
if (!tbody) return;
mergeInfo.forEach(info => {
const startRow = info.rowIndex;
const startCol = info.colIndex;
const colCount = info.colCount || 1; // 默认合并1列
// 获取需要合并的起始行
const rowToMerge = tbody.rows[startRow];
if (!rowToMerge) return;
// 合并列
if (colCount > 1) {
const cellToMerge = rowToMerge.cells[startCol];
if (!cellToMerge) return;
cellToMerge.colSpan = colCount;
// 删除后续的单元格
for (let i = 1; i < colCount; i++) {
const cell = rowToMerge.cells[startCol + i];
if (!cell) break;
cell.style.display = 'none'; // 隐藏单元格而不是删除
// 标记为稍后删除
cell.classList.add('remove-later');
}
}
});
document.querySelectorAll('.remove-later').forEach(cell => cell.remove());
},
3.源码
直接铁源码吧
javascript
<template>
<div class="lcdp_axe_main projectProgress">
<div class="search-row">
<div>
<span class="ins_format default">时间范围</span>
<el-date-picker
size="mini"
v-model="month"
:clearable="false"
type="daterange"
value-format="yyyy-MM-dd"
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="changeDate">
</el-date-picker>
</div>
</div>
<div class="status-box">
<p class="title">
项目进度
</p>
<p>
<span>状态:</span>
<span class="green-point"></span>
<span>计划</span>
<span class="blue-point"></span>
<span>已完成</span>
<span class="orange-point"></span>
<span>进行中</span>
</p>
</div>
<div class="gantt-box">
<table id="gantt-table" :key="tabKey" :style="'width:' + tableWidth +'px'" cellspacing="0">
<thead>
<tr>
<th style="width:100px" rowspan="2">
序号
</th>
<th style="width:200px" rowspan="2">
委托名称
</th>
<th style="width: 200px" rowspan="2">
项目名称
</th>
<th style="width: 300px" colspan="3">
时间节点
</th>
<th :colspan="dateRange.length">
项目进度
</th>
</tr>
<tr>
<th>
执行
</th>
<th>
开始
</th>
<th>
结束
</th>
<th style="width: 100px" v-for="(item, index) in dateRange" :key="index">
{{ item.day.slice(5) }}
<br/>
{{ item.week }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in planData" :key="index">
<td v-if="index%2===0" rowspan="2">{{ index / 2 + 1 }}</td>
<td v-if="index%2===0" rowspan="2">{{ item.entrustName }}</td>
<td v-if="index%2===0" rowspan="2">{{ item.planName }}</td>
<td>{{ index % 2 === 0 ? '计划' : '实际' }}</td>
<td>{{ item.startTime }}</td>
<td>{{ item.endTime }}</td>
<td v-for="(cell, ind) in dateRange" :key="'cell' + ind"
:ref="index%2===0?'p-':'r-'+item.planId+'-' + cell.day">
<span :class="{'planBar': item.status === 0}"
@click="granttCick(item,index)"
v-if="item.status === 0&&isBetweenCheck(item,cell.day)">{{ item.showName }}</span>
<span :class="{'doingBar': item.status === 1}"
@click="granttCick(item,index)"
v-if="item.status === 1&&isBetweenCheck(item,cell.day)">{{ item.showName }}</span>
<span :class="{'completedBar': item.status === 2}"
@click="granttCick(item,index)"
v-if="item.status === 2&&isBetweenCheck(item,cell.day)">{{ item.showName }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<el-dialog
title="计划名称"
:close-on-click-modal="false"
:visible.sync="dialogVisible"
width="30%">
<el-form ref="planForm" :rules="rules" size="mini" :model="planForm" label-width="80px">
<el-form-item label="计划名称" prop="showName">
<el-input v-model="planForm.showName"></el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button size="mini" @click="dialogVisible = false">取 消</el-button>
<el-button size="mini" type="primary" @click="save">确 定</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import moment from 'moment';
export default {
name: "projectProgress",
data() {
return {
tabKey: Math.random(),
month: [moment().format('YYYY-MM') + '-01', moment().format('YYYY-MM') + '-' + moment().daysInMonth()],
dateRange: [],
planData: [],
tableWidth: 800,
mergeColumnsData: [],
dialogVisible: false,
isPlanFlag: true,
planForm: {},
rules: {
showName: {required: true, message: '请输入计划名称', trigger: 'change'}
}
}
},
async mounted() {
await this.getDaysWeek();
await this.getTaskProgressList();
},
methods: {
async getTaskProgressList() {
const self = this;
let params = {
planStartTime: self.month[0],
planEndTime: self.month[1]
}
// self.planData = [];
self.planData = [
{
endTime: '2024-10-05',
entrustName: '一个大的项目名称',
planId: '1',
planName: '测试项目1',
showName: '计划中',
startTime: '2024-10-02',
status: 0
}, {
endTime: "2024-10-04",
entrustName: "一个大的项目名称",
planId: "1",
planName: "测试项目1",
showName: "赶工完成",
startTime: "2024-10-02",
status: 2
},{
endTime: '2024-10-15',
entrustName: '一个小的项目名称',
planId: '2',
planName: '测试项目2',
showName: '计划中',
startTime: '2024-10-09',
status: 0
}, {
endTime: "",
entrustName: "一个小的项目名称",
planId: "2",
planName: "测试项目2",
showName: "实际执行",
startTime: "2024-10-10",
status: 1
}
];
this.$nextTick(() => {
this.handleColspanData();
})
},
changeDate() {
this.tabKey = Math.random();
this.getDaysWeek();
this.$nextTick(() => {
this.getTaskProgressList();
});
},
granttCick(data, index) {
this.isPlanFlag = index % 2 === 0 ? true : false;
this.planForm = JSON.parse(JSON.stringify(data));
this.dialogVisible = true;
},
save() {
const self = this;
this.$refs.planForm.validate((valid) => {
if (valid) {
let params = {
planId: self.planForm.planId,
}
if (self.isPlanFlag) {
params.showName = self.planForm.showName;
} else {
params.actualName = self.planForm.showName;
}
setShowName(params).then(res => {
if (res.code === 10000) {
self.getTaskProgressList();
} else {
}
}).catch(() => {
})
self.dialogVisible = false;
} else {
return false;
}
});
},
handleColspanData() {
const self = this;
let colIndex = 0;
self.mergeColumnsData = [];
self.planData.forEach((item, index) => {
colIndex = index % 2 === 0 ? 6 : 3;
self.dateRange.forEach((child, childIndex) => {
if (this.isBetweenCheck(item, child.day)) {
// 判断mergeColumnsData是否存在?存在则替换,反之插入
let id = index % 2 === 0 ? 'p-' + item.planId : 'r-' + item.planId;
let targetIndex = self.mergeColumnsData.findIndex(col => col.id === id);
if (targetIndex !== -1) {
self.mergeColumnsData[targetIndex].colCount = self.mergeColumnsData[targetIndex].colCount + 1
} else {
self.mergeColumnsData.push({
id: id,
rowIndex: index,
colIndex: colIndex + childIndex,
colCount: 1
});
}
}
})
});
self.mergeColumns('gantt-table', self.mergeColumnsData);
},
isBetweenCheck(data, current) {
let startTime = moment(data.startTime);
let endTime = moment(data.endTime === '' ? this.month[1] : data.endTime);
let dateToCheck = moment(current);
const isWithinRange = dateToCheck.isBetween(startTime, endTime, null, '[]');
return isWithinRange;
},
mergeColumns(tableId, mergeInfo) {
const table = document.getElementById(tableId);
if (!table) return;
// 获取tbody元素
const tbody = table.tBodies[0];
if (!tbody) return;
mergeInfo.forEach(info => {
const startRow = info.rowIndex;
const startCol = info.colIndex;
const colCount = info.colCount || 1; // 默认合并1列
// 获取需要合并的起始行
const rowToMerge = tbody.rows[startRow];
if (!rowToMerge) return;
// 合并列
if (colCount > 1) {
const cellToMerge = rowToMerge.cells[startCol];
if (!cellToMerge) return;
cellToMerge.colSpan = colCount;
// 删除后续的单元格
for (let i = 1; i < colCount; i++) {
const cell = rowToMerge.cells[startCol + i];
if (!cell) break;
cell.style.display = 'none'; // 隐藏单元格而不是删除
// 标记为稍后删除
cell.classList.add('remove-later');
}
}
});
document.querySelectorAll('.remove-later').forEach(cell => cell.remove());
},
getDaysWeek() {
this.dateRange = [];
const diffDays = moment(this.month[1]).diff(moment(this.month[0]), 'days') + 1;
this.tableWidth = 800 + 100 * diffDays;
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const dayOfWeek = moment(this.month[0]).day();
for (let i = 0; i < diffDays; i++) {
const day = moment(this.month[0]).add(i, 'days').format('YYYY-MM-DD');
const week = weekDays[(dayOfWeek + i) % 7]
this.dateRange.push({day: day, week: week})
}
}
}
}
</script>
<style lang="scss" scoped>
.lcdp_axe_main {
overflow: auto;
width: calc(100% - 20px);
height: calc(100% - 20px);
position: relative;
top: 10px;
left: 10px;
right: 10px;
bottom: 10px;
display: block;
padding: 12px;
background: #ffffff;
.search-row {
height: 40px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--c-borderColor);;
.ins_format {
margin-right: 10px;
}
}
.status-box {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
height: 40px;
.title {
font-weight: bold;
padding-left: 5px;
border-left: 5px solid var(--c-themeColor);
}
.green-point, .blue-point, .orange-point {
display: inline-flex;
width: 15px;
height: 15px;
border-radius: 50%;
margin-left: 20px;
margin-right: 5px;
}
.green-point {
background: #00b57b;
}
.blue-point {
background: #18aaf1;
}
.orange-point {
background: #f19418;
}
}
.gantt-box {
min-width: 100%;
height: calc(100% - 90px);
overflow: auto;
#gantt-table {
table-layout: fixed;
min-width: 100%;
td, th {
border: 1px solid #d5d9dc;
height: 45px;
text-align: center;
color: var(--c-mainTxtColor);
}
thead tr:nth-child(1),
tbody tr:nth-child(odd) {
td:nth-child(1),
th:nth-child(1) {
position: sticky;
left: 0;
z-index: 1;
background: #f7fbff;
}
td:nth-child(2),
th:nth-child(2) {
position: sticky;
left: 100px;
z-index: 1;
background: #f7fbff;
}
td:nth-child(3),
th:nth-child(3) {
position: sticky;
left: 300px;
z-index: 1;
background: #f7fbff;
}
td:nth-child(4),
th:nth-child(4) {
position: sticky;
left: 500px;
z-index: 1;
background: #f7fbff;
}
td:nth-child(5) {
position: sticky;
left: 600px;
z-index: 1;
background: #f7fbff;
}
td:nth-child(6) {
position: sticky;
left: 700px;
z-index: 1;
background: #f7fbff;
}
}
tbody tr:nth-child(even) {
td:nth-child(1) {
position: sticky;
left: 500px;
z-index: 1;
background: #f7fbff;
}
td:nth-child(2) {
position: sticky;
left: 600px;
z-index: 1;
background: #f7fbff;
}
td:nth-child(3) {
position: sticky;
left: 700px;
z-index: 1;
background: #f7fbff;
}
}
thead tr:nth-child(2) {
th:nth-child(1) {
position: sticky;
left: 500px;
z-index: 1;
background-color: lightpink;
}
th:nth-child(2) {
position: sticky;
left: 600px;
z-index: 1;
background-color: lightpink;
}
th:nth-child(3) {
position: sticky;
left: 700px;
z-index: 1;
background-color: lightpink;
}
}
thead tr:nth-child(1) {
th:nth-child(1),
th:nth-child(2),
th:nth-child(3),
th:nth-child(4) {
position: sticky;
background: #f7fbff;
z-index: 2;
top: 0px;
}
th {
position: sticky;
background: #f7fbff;
font-size: 16px;
font-weight: bold;
z-index: 1;
top: 0px;
}
}
thead tr:nth-child(2) {
th:nth-child(1),
th:nth-child(2),
th:nth-child(3) {
position: sticky;
z-index: 2;
background: #f7fbff;
top: 45px;
}
th {
position: sticky;
z-index: 1;
background: #f7fbff;
top: 45px;
}
}
tbody tr:nth-child(odd) td {
border-top: 2px solid var(--c-normalTxtColor);
}
.planBar {
display: inline-block;
width: 100%;
height: 60%;
background: #00b57b;
background-image: repeating-linear-gradient(
45deg,
hsla(0, 0%, 100%, 0.1),
hsla(0, 0%, 100%, 0.1) 15px,
transparent 0,
transparent 30px
);
}
.doingBar {
display: inline-block;
width: 100%;
height: 60%;
background: #f19418;
background-image: repeating-linear-gradient(
45deg,
hsla(0, 0%, 100%, 0.1),
hsla(0, 0%, 100%, 0.1) 15px,
transparent 0,
transparent 30px
);
}
.completedBar {
display: inline-block;
width: 100%;
height: 60%;
background: #18aaf1;
background-image: repeating-linear-gradient(
45deg,
hsla(0, 0%, 100%, 0.1),
hsla(0, 0%, 100%, 0.1) 15px,
transparent 0,
transparent 30px
);
}
span {
color: #ffffff;
font-weight: bold;
font-size: 18px;
cursor: pointer;
}
span:hover {
box-shadow: 0 0 5px 5px #eaeaea;
background-image: url("~@/assets/img/icon-edit.png");
background-repeat: no-repeat;
background-size: auto 80%;
background-position: 10px center;
}
}
}
}
</style>