
本文介绍了一个基于Element UI的工序写实图例详情弹窗组件。该组件采用甘特图形式可视化展示隧道施工中的各工序时间分布,包括钻孔、装药、出渣等7个主要工序节点。组件主要功能特点: 可视化展示:通过彩色进度条展示各工序时间段,支持状态颜色区分(未开始、进行中、已完成、已跳过) 交互功能: 支持工序段悬浮提示,显示详细信息 响应式设计适配不同屏幕 滚动加载更多数据 技术实现: 使用Vue3+TypeScript开发 动态计算时间轴刻度 支持工序状态颜色映射 实现工序段位置自动计算 该组件适用于隧道施工进度可
html
<template>
<el-dialog class="eldialoggxxs" v-model="dialogVisible" width="80%" :close-on-click-modal="false"
:close-on-press-escape="false" :show-close="false" :lock-scroll="true">
<div class="closeClassgxxs" @click="handleClose"></div>
<div class="topClassel">
<div class="title">工序写实图例详情</div>
</div>
<div class="tableBox" ref="tableBoxRef">
<div class="legendtype">
<div class="legend-item" v-for="li in legendItems" :key="li.name">
<span class="legend-dot" :style="{ backgroundColor: li.color }"></span>
<span class="legend-text">{{ li.name }}</span>
</div>
</div>
<div class="table-pagination-box" ref="scrollBoxRef">
<div class="loop-row" v-for="(item, idx) in tableData" :key="idx">
<div class="row-header">
<div class="left-info">
<div class="loop-name">{{ item.loopUnitName }}</div>
<div class="stats">
<!-- 用时{{ formatMsToZh(item.loopUnitRecordTime) }} -->
<span class="left-info-span"> 工点名称:</span>{{ item.siteName }}
<span class="spacer">|</span>
<span class="left-info-span"> 掌子面里程:</span>{{ item.excavationMileage }}
<span class="spacer">|</span>
<span class="left-info-span"> 开挖工法:</span>{{ item.excavationMethod }}
<span class="spacer">|</span>
<span class="left-info-span"> 围岩等级:</span>{{ item.rockLevel }}
<!-- 进尺{{ calcAdvance(item.startPileNumber, item.endPileNumber) }}米 -->
</div>
</div>
</div>
<div class="bar-area">
<div class="bar-bg" :style="{ height: lanesTotalHeight + 'px' }">
<!-- 垂直虚线分割(与 X 轴刻度对齐) -->
<div class="gridline" v-for="i in axisHours + 1" :key="'g-' + i"
:style="{ left: getTickLeft(i - 1) }"></div>
<div class="lane" v-for="(laneName, laneIdx) in laneOrder" :key="laneName"
:style="{ top: getLaneTop(laneIdx) + 'px' }">
<div class="segment" v-for="(p, i) in getLaneSegments(item, laneName)" :key="i"
:style="getSegmentStyle(p, item)" @mouseenter="onSegEnter($event, p, item)"
@mouseleave="onSegLeave">
<span class="seg-label">{{ p.nodeName }}{{ p.processWorkTime }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 固定在底部的 X 轴(不随滚动) -->
<div class="axis" v-if="axisHours > 0">
<!-- 刻度分隔:仅用于显示右侧边框线 -->
<div class="axis-tick" v-for="h in axisHours + 1" :key="h" :style="{ width: tickWidthPercent }"></div>
<!-- 文字与 gridline 对齐显示在底部 -->
<div class="axis-labels">
<span class="axis-label" v-for="h in axisHours + 1" :key="'al-' + h"
:style="{ left: gxComputeddatteNum(h) }">
{{ h - 1 < 0 ? 0 : h - 1 }} </span>
</div>
</div>
<!-- 悬浮提示:分行展示并为状态文字着色 -->
<div v-if="hoverTip.show" class="hover-tip" :style="{ left: hoverTip.x + 'px', top: hoverTip.y + 'px' }">
<div>
<span class="tip-dot" :style="{ backgroundColor: hoverTip.typeColor }"></span>
{{ hoverTip.name }} {{ hoverTip.duration }}
</div>
<div>状态: <span :style="{ color: hoverTip.statusColor }">{{ hoverTip.statusText }}</span></div>
<div>开始时间: {{ hoverTip.start }}</div>
<div>结束时间: {{ hoverTip.end }}</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import dayjs from 'dayjs';
// props
const props = defineProps({
processId: {
type: String,
default: '',
},
initialData: {
type: Object,
default: () => ({}),
},
});
const dialogVisible = ref(false);
const tableData = ref<any[]>([]);
let myChart: any = null;
const scrollBoxRef = ref<HTMLElement | null>(null);
const tableBoxRef = ref<HTMLElement | null>(null);
const isLoadingMore = ref(false);
const hasMore = ref(true);
const hoverTip = ref<{ show: boolean; text: string; x: number; y: number; statusText?: string; statusColor?: string; name?: string; duration?: string; start?: string; end?: string; typeColor?: string }>({ show: false, text: '', x: 0, y: 0 });
const queryForm = reactive<any>({
page: 1,
pageSize: 20,
beginDate: "",
endDate: "",
});
// 发射事件
const emit = defineEmits(["on-success-detail"]);
// 示例数据(用于预览)
const getTestData = async (append = false) => {
tableData.value = tableData.value = [
{
"siteName": "隧道",
"excavationMileage": "DK10+669",
"rockLevel": "III",
"excavationMethod": "全断面法",
"currentProcessName": "初支喷砼",
"createDate": "2025-11-09 00:03:44",
"child": [
{
"nodeName": "钻孔",
"nodeStatus": 2,
"nodeRank": 1,
"startDate": "2025-11-09 00:03:47",
"endDate": "2025-11-09 01:38:05",
"processWorkTime": "0天1时34分",
"intervalTime": null,
},
{
"nodeName": "装药",
"nodeStatus": 2,
"nodeRank": 2,
"startDate": "2025-11-09 02:49:47",
"endDate": "2025-11-09 05:47:33",
"processWorkTime": "0天2时57分",
"intervalTime": "0天1时71分",
},
{
"nodeName": "出渣",
"nodeStatus": 2,
"nodeRank": 3,
"startDate": "2025-11-09 05:47:35",
"endDate": "2025-11-09 09:39:09",
"processWorkTime": "0天3时51分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "初喷",
"nodeStatus": 2,
"nodeRank": 4,
"startDate": "2025-11-09 09:39:11",
"endDate": "2025-11-09 09:39:12",
"processWorkTime": "0天0时0分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "立架",
"nodeStatus": 2,
"nodeRank": 5,
"startDate": "2025-11-09 09:39:16",
"endDate": "2025-11-09 15:11:34",
"processWorkTime": "0天5时32分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "超前、锚杆",
"nodeStatus": 2,
"nodeRank": 6,
"startDate": "2025-11-09 15:11:37",
"endDate": "2025-11-09 15:11:39",
"processWorkTime": "0天0时0分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "初支喷砼",
"nodeStatus": 2,
"nodeRank": 7,
"startDate": "2025-11-09 15:11:40",
"endDate": "2025-11-09 16:28:25",
"processWorkTime": "0天1时16分",
"intervalTime": "0天0时0分",
}
]
},
{
"siteName": "隧道",
"excavationMileage": "DK10+681",
"rockLevel": "II",
"excavationMethod": "全断面法",
"currentProcessName": "初支喷砼",
"createDate": "2025-11-07 13:55:59",
"child": [
{
"nodeName": "钻孔",
"nodeStatus": 2,
"nodeRank": 1,
"startDate": "2025-11-07 13:56:13",
"endDate": "2025-11-07 15:53:03",
"processWorkTime": "0天1时56分",
"intervalTime": null,
},
{
"nodeName": "装药",
"nodeStatus": 2,
"nodeRank": 2,
"startDate": "2025-11-07 15:53:05",
"endDate": "2025-11-07 16:41:22",
"processWorkTime": "0天0时48分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "出渣",
"nodeStatus": 2,
"nodeRank": 3,
"startDate": "2025-11-07 16:41:23",
"endDate": "2025-11-08 00:55:53",
"processWorkTime": "0天8时14分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "初喷",
"nodeStatus": 3,
"nodeRank": 4,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "立架",
"nodeStatus": 3,
"nodeRank": 5,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "超前、锚杆",
"nodeStatus": 3,
"nodeRank": 6,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "初支喷砼",
"nodeStatus": 3,
"nodeRank": 7,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
"situationInstructions": "连炮",
}
]
},
{
"siteName": "隧道",
"excavationMileage": "DK10+685",
"rockLevel": "III",
"excavationMethod": "全断面法",
"currentProcessName": "初支喷砼",
"createDate": "2025-11-06 15:29:33",
"child": [
{
"nodeName": "钻孔",
"nodeStatus": 2,
"nodeRank": 1,
"startDate": "2025-11-06 15:29:35",
"endDate": "2025-11-07 01:19:19",
"processWorkTime": "0天9时49分",
"intervalTime": null,
},
{
"nodeName": "装药",
"nodeStatus": 3,
"nodeRank": 2,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
"situationInstructions": "19:14-20:23",
},
{
"nodeName": "出渣",
"nodeStatus": 3,
"nodeRank": 3,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
"situationInstructions": "21:10-1:01",
},
{
"nodeName": "初喷",
"nodeStatus": 2,
"nodeRank": 4,
"startDate": "2025-11-07 01:19:52",
"endDate": "2025-11-07 03:05:25",
"processWorkTime": "0天1时45分",
"intervalTime": null,
},
{
"nodeName": "立架",
"nodeStatus": 2,
"nodeRank": 5,
"startDate": "2025-11-07 03:05:28",
"endDate": "2025-11-07 06:33:55",
"processWorkTime": "0天3时28分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "超前、锚杆",
"nodeStatus": 3,
"nodeRank": 6,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "初支喷砼",
"nodeStatus": 2,
"nodeRank": 7,
"startDate": "2025-11-07 06:34:02",
"endDate": "2025-11-07 09:46:57",
"processWorkTime": "0天3时12分",
"intervalTime": null,
}
]
},
{
"siteName": "隧道",
"excavationMileage": "DK10+689",
"rockLevel": "II",
"excavationMethod": "全断面法",
"currentProcessName": "立架",
"createDate": "2025-11-06 04:32:35",
"child": [
{
"nodeName": "钻孔",
"nodeStatus": 3,
"nodeRank": 1,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "装药",
"nodeStatus": 2,
"nodeRank": 2,
"startDate": "2025-11-06 04:32:53",
"endDate": "2025-11-06 08:41:09",
"processWorkTime": "0天4时8分",
"intervalTime": null,
},
{
"nodeName": "出渣",
"nodeStatus": 2,
"nodeRank": 3,
"startDate": "2025-11-06 08:41:11",
"endDate": "2025-11-06 10:54:44",
"processWorkTime": "0天2时13分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "初喷",
"nodeStatus": 2,
"nodeRank": 4,
"startDate": "2025-11-06 10:54:46",
"endDate": "2025-11-06 11:12:21",
"processWorkTime": "0天0时17分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "立架",
"nodeStatus": 0,
"nodeRank": 5,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "超前、锚杆",
"nodeStatus": 0,
"nodeRank": 6,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "初支喷砼",
"nodeStatus": 0,
"nodeRank": 7,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
}
]
},
];
};
const gxstatusComputed = computed(() => {
return (param: any) => {
if (param == "0") return "未开始";
if (param == "1") return "进行中";
if (param == "2") return "已完成";
if (param == "3") return "已跳过";
return "--";
};
});
// 将 processWorkTime 解析为毫秒,仅用于计算(显示仍使用原值)
const parseDurationToMs = (val: any): number => {
if (val == null) return 0;
if (typeof val === 'number') return isFinite(val) ? val : 0;
if (typeof val === 'string') {
const s = val.trim();
const dMatch = s.match(/(\d+(?:\.\d+)?)\s*d/i);
const hMatch = s.match(/(\d+(?:\.\d+)?)\s*h/i);
const mMatch = s.match(/(\d+(?:\.\d+)?)\s*m(?!s)/i);
if (dMatch || hMatch || mMatch) {
const d = dMatch ? parseFloat(dMatch[1]) : 0;
const h = hMatch ? parseFloat(hMatch[1]) : 0;
const m = mMatch ? parseFloat(mMatch[1]) : 0;
return Math.round(d * 86400000 + h * 3600000 + m * 60000);
}
const dz = s.match(/(\d+(?:\.\d+)?)\s*天/);
const hz = s.match(/(\d+(?:\.\d+)?)\s*时/);
const mz = s.match(/(\d+(?:\.\d+)?)\s*分/);
if (dz || hz || mz) {
const d = dz ? parseFloat(dz[1]) : 0;
const h = hz ? parseFloat(hz[1]) : 0;
const m = mz ? parseFloat(mz[1]) : 0;
return Math.round(d * 86400000 + h * 3600000 + m * 60000);
}
const hm = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
if (hm) {
const h = parseInt(hm[1]) || 0;
const m = parseInt(hm[2]) || 0;
const sec = hm[3] ? parseInt(hm[3]) || 0 : 0;
return h * 3600000 + m * 60000 + sec * 1000;
}
const num = Number(s);
return isFinite(num) ? num : 0;
}
return 0;
};
// X轴总小时(向上取整):按每条记录 child 内 processWorkTime 累加,取最大
const axisHours = computed(() => {
const items = tableData.value || [];
const totalsMs = items.map((it: any) => {
const child = it?.child || [];
const sumMs = child.reduce((acc: number, p: any) => acc + parseDurationToMs(p?.processWorkTime), 0);
return sumMs;
});
const maxMs = totalsMs.length ? Math.max(...totalsMs) : 0;
return maxMs > 0 ? Math.ceil(maxMs / 3600000) : 0;
});
const tickWidthPercent = computed(() => {
const ah = axisHours.value;
if (!ah || ah <= 0) return '0%';
return `${100 / (ah + 1)}%`;
});
// 每个刻度的左侧百分比位置(用于虚线分割)
const getTickLeft = (idx: number) => {
const ah = axisHours.value;
if (!ah || ah <= 0) return '0%';
const left = (idx / ah) * 100;
return `${left}%`;
};
// 颜色映射与车道配置
const laneOrder = ['钻孔', '装药', '出渣', '初喷', '立架', '超前、锚杆', '初支喷砼'];
const colorMap: Record<string, string> = {
'钻孔': '#5E92F7',
'装药': '#8BF4C8',
'出渣': '#50b332',
'初喷': '#E9A252',
'立架': '#f87be2',
'超前、锚杆': '#F14864',
'初支喷砼': '#9A6FF6',
// '空闲': '#ccc',
};
const gxstatusComputedColor = computed(() => {
return (param: any) => {
if (param == "未开始") return "red";
if (param == "进行中") return "orange";
if (param == "已完成") return "#67c23a";
if (param == "已跳过") return "#ccc";
return "#000000";
};
});
const gxComputeddatteNum = computed(() => {
return (param: any) => {
if (param < 10) return getTickLeft(param - 0.8);
else if (param >= 10 && param < 20) return getTickLeft(param - 1);
else if (param >= 20 && param < 30) return getTickLeft(param - 1.2);
return getTickLeft(param - 1.2)
};
});
const lanesTotalHeight = 45 + (laneOrder.length - 1) * 25; // 每车道10px,间距25px
const getLaneTop = (idx: number) => (idx + 1) * 25; // 依次间距25px
const colorFor = (name: string) => colorMap[name] || '#999';
// 图例:根据 laneOrder 生成名称与颜色
const legendItems = computed(() => laneOrder.map((name) => ({ name, color: colorFor(name) })));
const getLaneSegments = (item: any, laneName: string) => {
const list = (item.child || []).filter((p: any) => p && p.nodeName === laneName);
if (laneName === '空闲') {
const usedMs = (item.child || []).reduce((sum: number, p: any) => sum + parseDurationToMs(p?.processWorkTime), 0);
const totalMs = Number(item.loopUnitRecordTime) || 0;
const idleMs = Math.max(0, totalMs - usedMs);
if (idleMs > 0) list.push({ nodeName: '空闲', processWorkTime: idleMs });
}
return list;
};
// 计算段在 X 轴上的 left/width 百分比;跨午夜时按 24h 回绕
const getLeftWidthPercent = (startMsOffset: number, durationMs: number) => {
const totalMs = axisHours.value * 3600000;
if (!totalMs) return { left: '0%', width: '0%' };
const leftPct = (Math.max(0, startMsOffset) / totalMs) * 100;
const widthPct = (Math.max(0, durationMs) / totalMs) * 100;
return { left: `${leftPct}%`, width: `${widthPct}%` };
};
// 将十六进制颜色转换为 rgba,透明度 0.8
const hexToRgba = (hex: string, alpha = 0.7) => {
const h = hex.replace('#', '');
const bigint = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
// 组合段样式:颜色透明0.8+边框1px,位置由 startTimeStr/endTimeStr 决定
// 计算段的起点偏移(毫秒):按记录顺序累计之前段的用时,实现"后段从前段结束处开始"
const getStartOffsetMs = (item: any, seg: any): number => {
const arr = (item?.child || []) as any[];
let sum = 0;
for (let i = 0; i < arr.length; i++) {
const it = arr[i];
if (it === seg || (
it?.nodeName === seg?.nodeName &&
it?.startDate === seg?.startDate &&
it?.endDate === seg?.endDate &&
it?.processWorkTime === seg?.processWorkTime
)) {
break;
}
sum += parseDurationToMs(it?.processWorkTime);
}
return sum;
};
const getSegmentStyle = (p: any, item: any) => {
const axisTotalMs = axisHours.value * 3600000;
if (!axisTotalMs) {
const baseColor0 = colorFor(p?.nodeName || '');
const bg0 = hexToRgba(baseColor0, 0.7);
return {
position: 'absolute',
left: '0%',
width: '0%',
background: bg0,
border: `1px solid ${baseColor0}`,
} as any;
}
const durationMs = Math.max(0, parseDurationToMs(p?.processWorkTime));
const startOffsetMs = Math.max(0, getStartOffsetMs(item, p));
const { left, width } = getLeftWidthPercent(startOffsetMs, durationMs);
const baseColor = colorFor(p?.nodeName || '');
const bg = hexToRgba(baseColor, 0.7);
return {
position: 'absolute',
left,
width,
background: bg,
border: `1px solid ${baseColor}`,
} as any;
};
// 悬浮提示:展示到秒
const ensureSeconds = (v: any) => {
if (v == null) return '--';
if (typeof v === 'number') return dayjs(v).format('YYYY-M-D H:mm:ss');
if (typeof v === 'string') {
if (/\d{1,2}:\d{2}:\d{2}$/.test(v)) return v;
if (/\d{1,2}:\d{2}$/.test(v)) return v + ':00';
const d = dayjs(v);
if (d.isValid()) return d.format('YYYY-M-D H:mm:ss');
return v;
}
return String(v);
};
const buildTooltipText = (p: any) => {
const name = p?.nodeName || '--';
const duration = String(p?.processWorkTime ?? '--');
const s = ensureSeconds(p?.startDate);
const e = ensureSeconds(p?.endDate);
const statusText = gxstatusComputed.value(p?.nodeStatus ?? '0');
// 三行展示:第一行为"类型+用时",第二行为"开始时间",第三行为"结束时间"
return `${name} ${duration}\n开始时间:${s}\n结束时间:${e}\n状态:${statusText}`;
};
const onSegEnter = (ev: MouseEvent, p: any, item?: any) => {
const box = tableBoxRef.value;
if (!box) return;
const rect = box.getBoundingClientRect();
const name = p?.nodeName || '--';
const duration = String(p?.processWorkTime ?? '--');
const s = ensureSeconds(p?.startDate);
const e = ensureSeconds(p?.endDate);
const statusText = gxstatusComputed.value((item?.nodeStatus ?? p?.nodeStatus ?? '0'));
const statusColor = gxstatusComputedColor.value(statusText);
const typeColor = colorFor(p?.nodeName || '');
hoverTip.value = {
show: true,
text: buildTooltipText(p),
x: ev.clientX - rect.left + 12,
y: ev.clientY - rect.top + 12,
statusText,
statusColor,
name,
duration,
start: s,
end: e,
typeColor,
};
};
const onSegLeave = () => {
hoverTip.value.show = false;
};
// 滚动与分页
const handleScroll = (e: Event) => {
const el = e.target as HTMLElement;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
if (nearBottom) {
loadMore();
}
};
const loadMore = async () => {
if (isLoadingMore.value || !hasMore.value) return;
isLoadingMore.value = true;
// 使用 queryForm.page 加一实现滚动加载分页
queryForm.page = (queryForm.page || 1) + 1;
await getTestData(true);
isLoadingMore.value = false;
};
// 暴露方法
const init = async () => {
dialogVisible.value = true;
// 重置分页与数据
queryForm.page = 1;
hasMore.value = true;
tableData.value = [];
await getTestData(false);
await nextTick();
// 绑定滚动事件
if (scrollBoxRef.value) {
scrollBoxRef.value.addEventListener('scroll', handleScroll);
}
};
watch(() => props.processId, () => {
getTestData();
});
const handleClose = () => {
dialogVisible.value = false;
emit("on-success-detail");
if (myChart) {
myChart.dispose();
myChart = null;
}
if (scrollBoxRef.value) {
scrollBoxRef.value.removeEventListener('scroll', handleScroll);
}
};
defineExpose({ init });
onMounted(() => {
window.addEventListener('resize', () => {
if (myChart) myChart.resize();
});
if (scrollBoxRef.value) {
scrollBoxRef.value.addEventListener('scroll', handleScroll);
}
});
onBeforeUnmount(() => {
if (myChart) {
myChart.dispose();
myChart = null;
}
if (scrollBoxRef.value) {
scrollBoxRef.value.removeEventListener('scroll', handleScroll);
}
});
</script>
<style lang="scss" scoped>
.closeClassgxxs {
width: 35px;
height: 35px;
position: absolute;
top: 20px;
right: 20px;
background: linear-gradient(135deg,
rgba(255, 77, 79, 0.8) 0%,
rgba(255, 123, 123, 0.8) 50%,
rgba(255, 77, 79, 0.8) 100%);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 0 15px rgba(255, 77, 79, 0.4);
display: flex;
align-items: center;
justify-content: center;
&::before {
content: '×';
font-size: 24px;
color: #fff;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
line-height: 1;
}
&::after {
content: '';
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
border-radius: 50%;
border: 2px solid rgba(255, 77, 79, 0.6);
animation: close-btn-pulse 2s ease-in-out infinite;
pointer-events: none;
}
&:hover {
background: linear-gradient(135deg,
rgba(255, 77, 79, 1) 0%,
rgba(255, 123, 123, 1) 50%,
rgba(255, 77, 79, 1) 100%);
box-shadow: 0 0 20px rgba(255, 77, 79, 0.6);
transform: scale(1.1);
&::before {
text-shadow: 0 0 15px rgba(255, 255, 255, 1);
}
}
}
.topClassel {
width: 100%;
height: 50px;
display: flex;
justify-content: space-between;
.title {
width: 30%;
height: 70%;
font-weight: 700;
font-style: normal;
font-size: 18px;
// color: #fff;
border-image-slice: 1;
}
}
// 表格容器样式覆盖
.tableBox {
max-height: 80vh; // 最大高度为视口高度的80%
overflow: hidden; // 防止内容溢出
position: relative;
display: flex;
flex-direction: column;
:deep(.el-range-input) {
color: #606266;
}
.legendtype {
height: 30px;
width: 100%;
}
.table-pagination-box {
// height: 100%;
height: 70vh;
display: flex;
flex-direction: column;
// gap: 8px;
gap: 1px;
overflow-y: auto;
/* 轴样式移至外层 */
.axis-tick {
position: relative;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #9fb3c8;
font-size: 12px;
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.axis-tick:last-child {
border-right: none;
}
.axis-label {
transform: translateY(0);
}
.loop-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
// border-radius: 8px;
// background: rgba(12, 23, 43, 0.25);
// background: rgba(55, 79, 105);
background: #133573;
height: 290px;
}
.row-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.left-info {
display: flex;
flex-direction: column;
gap: 6px;
color: #e6effa;
}
.loop-name {
font-weight: 600;
font-size: 14px;
}
.stats {
// color: #9fb3c8;
color: #fff;
font-size: 13px;
.left-info-span {
color: #9fb3c8;
}
}
.stats .spacer {
margin: 0 6px;
color: #576b85;
}
.bar-area {
width: 100%;
}
.bar-bg {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 6px;
background: rgba(34, 56, 92, 0.25);
border: 1px solid rgba(255, 255, 255, 0.12);
/* 刻度向上虚线分割 */
.gridline {
position: absolute;
top: 0;
bottom: 0;
width: 0;
// border-left: 1px dashed rgba(255, 255, 255, 0.22);
border-left: 1px dashed rgb(204, 204, 204, 0.4);
pointer-events: none;
}
}
.lane {
position: absolute;
left: 0;
right: 0;
height: 10px;
display: flex;
}
.segment {
position: relative;
height: 100%;
display: flex;
align-items: center;
padding: 0 4px;
white-space: nowrap;
background: #5E92F7; // 默认值,具体颜色由内联样式覆盖
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.segment:nth-child(odd) {
/* 保留分段分隔线 */
}
.seg-label {
position: absolute;
bottom: 100%;
transform: translateY(-2px);
left: 0;
font-size: 11px;
color: #ffffff;
font-weight: 600;
pointer-events: none;
white-space: nowrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.right-info {
color: #9fb3c8;
font-size: 13px;
text-align: right;
}
.right-info .pile {
margin-left: 6px;
color: #e6effa;
}
}
/* 外层固定底部 X 轴样式 */
>.axis {
position: relative;
display: flex;
align-items: center;
width: 100%;
background: rgba(12, 23, 43, 0.6);
// border: 1px solid rgba(255, 255, 255, 0.18);
// border-right: 1px solid rgba(255, 255, 255, 0.12);
// border-radius: 6px;
overflow: hidden;
box-sizing: border-box;
// margin-top: 8px;
}
>.axis .axis-tick {
position: relative;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #e6effa;
font-size: 12px;
// border-right: 1px solid rgba(255, 255, 255, 0.12);
}
>.axis .axis-tick:last-child {
border-right: none;
}
>.axis .axis-label {
position: absolute;
bottom: 4px;
transform: translateX(-50%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
color: #fff
}
>.axis .axis-labels {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 100%;
pointer-events: none;
}
/* 悬浮提示 */
.hover-tip {
position: absolute;
max-width: 360px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(20, 30, 50, 0.95);
color: #ffffff;
font-size: 12px;
line-height: 1.4;
white-space: pre-line;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.12);
pointer-events: none;
z-index: 1000;
}
.hover-tip .tip-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
border: 1px solid rgba(255, 255, 255, 0.3);
vertical-align: middle;
}
/* 图例样式 */
.legendtype {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 6px 10px;
margin-bottom: 6px;
// color: #e6effa;
color: black;
font-size: 14px;
width: 100%;
min-height: 30px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 13px;
height: 13px;
border-radius: 50%;
display: inline-block;
border: 1px solid rgba(255, 255, 255, 0.5);
// box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.legend-text {
line-height: 1;
}
}
</style>
<style lang="scss">
.eldialoggxxs {
.el-dialog__header {
padding-bottom: 0px !important
}
}
</style>
javascript
<GanttChartGX ref="infoDetailsRefGX" v-if="infoDetailsFollowBoxGX" @on-success-detail="onSuccessInfoGX">
</GanttChartGX>
const infoDetailsRefGX = ref<any>(null);
const infoDetailsFollowBoxGX = ref(false);
const onSuccessInfoGX = () => {
infoDetailsFollowBoxGX.value = false;
};
//点击打开组件弹框
const handleGanttChart = async () => {
infoDetailsFollowBoxGX.value = true;
await nextTick();
infoDetailsRefGX.value.init();
}
<template>
<el-dialog class="eldialoggxxs" v-model="dialogVisible" width="80%" :close-on-click-modal="false"
:close-on-press-escape="false" :show-close="false" :lock-scroll="true">
<div class="closeClassgxxs" @click="handleClose"></div>
<div class="topClassel">
<div class="title">工序写实图例详情</div>
</div>
<div class="tableBox" ref="tableBoxRef">
<div class="legendtype">
<div class="legend-item" v-for="li in legendItems" :key="li.name">
<span class="legend-dot" :style="{ backgroundColor: li.color }"></span>
<span class="legend-text">{{ li.name }}</span>
</div>
</div>
<div class="table-pagination-box" ref="scrollBoxRef">
<div class="loop-row" v-for="(item, idx) in tableData" :key="idx">
<div class="row-header">
<div class="left-info">
<div class="loop-name">{{ item.loopUnitName }}</div>
<div class="stats">
<!-- 用时{{ formatMsToZh(item.loopUnitRecordTime) }} -->
<span class="left-info-span"> 工点名称:</span>{{ item.siteName }}
<span class="spacer">|</span>
<span class="left-info-span"> 掌子面里程:</span>{{ item.excavationMileage }}
<span class="spacer">|</span>
<span class="left-info-span"> 开挖工法:</span>{{ item.excavationMethod }}
<span class="spacer">|</span>
<span class="left-info-span"> 围岩等级:</span>{{ item.rockLevel }}
<!-- 进尺{{ calcAdvance(item.startPileNumber, item.endPileNumber) }}米 -->
</div>
</div>
</div>
<div class="bar-area">
<div class="bar-bg" :style="{ height: lanesTotalHeight + 'px' }">
<!-- 垂直虚线分割(与 X 轴刻度对齐) -->
<div class="gridline" v-for="i in axisHours + 1" :key="'g-' + i"
:style="{ left: getTickLeft(i - 1) }"></div>
<div class="lane" v-for="(laneName, laneIdx) in laneOrder" :key="laneName"
:style="{ top: getLaneTop(laneIdx) + 'px' }">
<div class="segment" v-for="(p, i) in getLaneSegments(item, laneName)" :key="i"
:style="getSegmentStyle(p, item)" @mouseenter="onSegEnter($event, p, item)"
@mouseleave="onSegLeave">
<span class="seg-label">{{ p.nodeName }}{{ p.processWorkTime }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 固定在底部的 X 轴(不随滚动) -->
<div class="axis" v-if="axisHours > 0">
<!-- 刻度分隔:仅用于显示右侧边框线 -->
<div class="axis-tick" v-for="h in axisHours + 1" :key="h" :style="{ width: tickWidthPercent }"></div>
<!-- 文字与 gridline 对齐显示在底部 -->
<div class="axis-labels">
<span class="axis-label" v-for="h in axisHours + 1" :key="'al-' + h"
:style="{ left: gxComputeddatteNum(h) }">
{{ h - 1 < 0 ? 0 : h - 1 }} </span>
</div>
</div>
<!-- 悬浮提示:分行展示并为状态文字着色 -->
<div v-if="hoverTip.show" class="hover-tip" :style="{ left: hoverTip.x + 'px', top: hoverTip.y + 'px' }">
<div>
<span class="tip-dot" :style="{ backgroundColor: hoverTip.typeColor }"></span>
{{ hoverTip.name }} {{ hoverTip.duration }}
</div>
<div>状态: <span :style="{ color: hoverTip.statusColor }">{{ hoverTip.statusText }}</span></div>
<div>开始时间: {{ hoverTip.start }}</div>
<div>结束时间: {{ hoverTip.end }}</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import dayjs from 'dayjs';
// props
const props = defineProps({
processId: {
type: String,
default: '',
},
initialData: {
type: Object,
default: () => ({}),
},
});
const dialogVisible = ref(false);
const tableData = ref<any[]>([]);
let myChart: any = null;
const scrollBoxRef = ref<HTMLElement | null>(null);
const tableBoxRef = ref<HTMLElement | null>(null);
const isLoadingMore = ref(false);
const hasMore = ref(true);
const hoverTip = ref<{ show: boolean; text: string; x: number; y: number; statusText?: string; statusColor?: string; name?: string; duration?: string; start?: string; end?: string; typeColor?: string }>({ show: false, text: '', x: 0, y: 0 });
const queryForm = reactive<any>({
page: 1,
pageSize: 20,
beginDate: "",
endDate: "",
});
// 发射事件
const emit = defineEmits(["on-success-detail"]);
// 示例数据(用于预览)
const getTestData = async (append = false) => {
tableData.value = tableData.value = [
{
"siteName": "隧道",
"excavationMileage": "DK10+669",
"rockLevel": "III",
"excavationMethod": "全断面法",
"currentProcessName": "初支喷砼",
"createDate": "2025-11-09 00:03:44",
"child": [
{
"nodeName": "钻孔",
"nodeStatus": 2,
"nodeRank": 1,
"startDate": "2025-11-09 00:03:47",
"endDate": "2025-11-09 01:38:05",
"processWorkTime": "0天1时34分",
"intervalTime": null,
},
{
"nodeName": "装药",
"nodeStatus": 2,
"nodeRank": 2,
"startDate": "2025-11-09 02:49:47",
"endDate": "2025-11-09 05:47:33",
"processWorkTime": "0天2时57分",
"intervalTime": "0天1时71分",
},
{
"nodeName": "出渣",
"nodeStatus": 2,
"nodeRank": 3,
"startDate": "2025-11-09 05:47:35",
"endDate": "2025-11-09 09:39:09",
"processWorkTime": "0天3时51分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "初喷",
"nodeStatus": 2,
"nodeRank": 4,
"startDate": "2025-11-09 09:39:11",
"endDate": "2025-11-09 09:39:12",
"processWorkTime": "0天0时0分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "立架",
"nodeStatus": 2,
"nodeRank": 5,
"startDate": "2025-11-09 09:39:16",
"endDate": "2025-11-09 15:11:34",
"processWorkTime": "0天5时32分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "超前、锚杆",
"nodeStatus": 2,
"nodeRank": 6,
"startDate": "2025-11-09 15:11:37",
"endDate": "2025-11-09 15:11:39",
"processWorkTime": "0天0时0分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "初支喷砼",
"nodeStatus": 2,
"nodeRank": 7,
"startDate": "2025-11-09 15:11:40",
"endDate": "2025-11-09 16:28:25",
"processWorkTime": "0天1时16分",
"intervalTime": "0天0时0分",
}
]
},
{
"siteName": "隧道",
"excavationMileage": "DK10+681",
"rockLevel": "II",
"excavationMethod": "全断面法",
"currentProcessName": "初支喷砼",
"createDate": "2025-11-07 13:55:59",
"child": [
{
"nodeName": "钻孔",
"nodeStatus": 2,
"nodeRank": 1,
"startDate": "2025-11-07 13:56:13",
"endDate": "2025-11-07 15:53:03",
"processWorkTime": "0天1时56分",
"intervalTime": null,
},
{
"nodeName": "装药",
"nodeStatus": 2,
"nodeRank": 2,
"startDate": "2025-11-07 15:53:05",
"endDate": "2025-11-07 16:41:22",
"processWorkTime": "0天0时48分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "出渣",
"nodeStatus": 2,
"nodeRank": 3,
"startDate": "2025-11-07 16:41:23",
"endDate": "2025-11-08 00:55:53",
"processWorkTime": "0天8时14分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "初喷",
"nodeStatus": 3,
"nodeRank": 4,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "立架",
"nodeStatus": 3,
"nodeRank": 5,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "超前、锚杆",
"nodeStatus": 3,
"nodeRank": 6,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "初支喷砼",
"nodeStatus": 3,
"nodeRank": 7,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
"situationInstructions": "连炮",
}
]
},
{
"siteName": "隧道",
"excavationMileage": "DK10+685",
"rockLevel": "III",
"excavationMethod": "全断面法",
"currentProcessName": "初支喷砼",
"createDate": "2025-11-06 15:29:33",
"child": [
{
"nodeName": "钻孔",
"nodeStatus": 2,
"nodeRank": 1,
"startDate": "2025-11-06 15:29:35",
"endDate": "2025-11-07 01:19:19",
"processWorkTime": "0天9时49分",
"intervalTime": null,
},
{
"nodeName": "装药",
"nodeStatus": 3,
"nodeRank": 2,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
"situationInstructions": "19:14-20:23",
},
{
"nodeName": "出渣",
"nodeStatus": 3,
"nodeRank": 3,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
"situationInstructions": "21:10-1:01",
},
{
"nodeName": "初喷",
"nodeStatus": 2,
"nodeRank": 4,
"startDate": "2025-11-07 01:19:52",
"endDate": "2025-11-07 03:05:25",
"processWorkTime": "0天1时45分",
"intervalTime": null,
},
{
"nodeName": "立架",
"nodeStatus": 2,
"nodeRank": 5,
"startDate": "2025-11-07 03:05:28",
"endDate": "2025-11-07 06:33:55",
"processWorkTime": "0天3时28分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "超前、锚杆",
"nodeStatus": 3,
"nodeRank": 6,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "初支喷砼",
"nodeStatus": 2,
"nodeRank": 7,
"startDate": "2025-11-07 06:34:02",
"endDate": "2025-11-07 09:46:57",
"processWorkTime": "0天3时12分",
"intervalTime": null,
}
]
},
{
"siteName": "隧道",
"excavationMileage": "DK10+689",
"rockLevel": "II",
"excavationMethod": "全断面法",
"currentProcessName": "立架",
"createDate": "2025-11-06 04:32:35",
"child": [
{
"nodeName": "钻孔",
"nodeStatus": 3,
"nodeRank": 1,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "装药",
"nodeStatus": 2,
"nodeRank": 2,
"startDate": "2025-11-06 04:32:53",
"endDate": "2025-11-06 08:41:09",
"processWorkTime": "0天4时8分",
"intervalTime": null,
},
{
"nodeName": "出渣",
"nodeStatus": 2,
"nodeRank": 3,
"startDate": "2025-11-06 08:41:11",
"endDate": "2025-11-06 10:54:44",
"processWorkTime": "0天2时13分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "初喷",
"nodeStatus": 2,
"nodeRank": 4,
"startDate": "2025-11-06 10:54:46",
"endDate": "2025-11-06 11:12:21",
"processWorkTime": "0天0时17分",
"intervalTime": "0天0时0分",
},
{
"nodeName": "立架",
"nodeStatus": 0,
"nodeRank": 5,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "超前、锚杆",
"nodeStatus": 0,
"nodeRank": 6,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
},
{
"nodeName": "初支喷砼",
"nodeStatus": 0,
"nodeRank": 7,
"startDate": null,
"endDate": null,
"processWorkTime": null,
"intervalTime": null,
}
]
},
];
};
const gxstatusComputed = computed(() => {
return (param: any) => {
if (param == "0") return "未开始";
if (param == "1") return "进行中";
if (param == "2") return "已完成";
if (param == "3") return "已跳过";
return "--";
};
});
// 将 processWorkTime 解析为毫秒,仅用于计算(显示仍使用原值)
const parseDurationToMs = (val: any): number => {
if (val == null) return 0;
if (typeof val === 'number') return isFinite(val) ? val : 0;
if (typeof val === 'string') {
const s = val.trim();
const dMatch = s.match(/(\d+(?:\.\d+)?)\s*d/i);
const hMatch = s.match(/(\d+(?:\.\d+)?)\s*h/i);
const mMatch = s.match(/(\d+(?:\.\d+)?)\s*m(?!s)/i);
if (dMatch || hMatch || mMatch) {
const d = dMatch ? parseFloat(dMatch[1]) : 0;
const h = hMatch ? parseFloat(hMatch[1]) : 0;
const m = mMatch ? parseFloat(mMatch[1]) : 0;
return Math.round(d * 86400000 + h * 3600000 + m * 60000);
}
const dz = s.match(/(\d+(?:\.\d+)?)\s*天/);
const hz = s.match(/(\d+(?:\.\d+)?)\s*时/);
const mz = s.match(/(\d+(?:\.\d+)?)\s*分/);
if (dz || hz || mz) {
const d = dz ? parseFloat(dz[1]) : 0;
const h = hz ? parseFloat(hz[1]) : 0;
const m = mz ? parseFloat(mz[1]) : 0;
return Math.round(d * 86400000 + h * 3600000 + m * 60000);
}
const hm = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
if (hm) {
const h = parseInt(hm[1]) || 0;
const m = parseInt(hm[2]) || 0;
const sec = hm[3] ? parseInt(hm[3]) || 0 : 0;
return h * 3600000 + m * 60000 + sec * 1000;
}
const num = Number(s);
return isFinite(num) ? num : 0;
}
return 0;
};
// X轴总小时(向上取整):按每条记录 child 内 processWorkTime 累加,取最大
const axisHours = computed(() => {
const items = tableData.value || [];
const totalsMs = items.map((it: any) => {
const child = it?.child || [];
const sumMs = child.reduce((acc: number, p: any) => acc + parseDurationToMs(p?.processWorkTime), 0);
return sumMs;
});
const maxMs = totalsMs.length ? Math.max(...totalsMs) : 0;
return maxMs > 0 ? Math.ceil(maxMs / 3600000) : 0;
});
const tickWidthPercent = computed(() => {
const ah = axisHours.value;
if (!ah || ah <= 0) return '0%';
return `${100 / (ah + 1)}%`;
});
// 每个刻度的左侧百分比位置(用于虚线分割)
const getTickLeft = (idx: number) => {
const ah = axisHours.value;
if (!ah || ah <= 0) return '0%';
const left = (idx / ah) * 100;
return `${left}%`;
};
// 颜色映射与车道配置
const laneOrder = ['钻孔', '装药', '出渣', '初喷', '立架', '超前、锚杆', '初支喷砼'];
const colorMap: Record<string, string> = {
'钻孔': '#5E92F7',
'装药': '#8BF4C8',
'出渣': '#50b332',
'初喷': '#E9A252',
'立架': '#f87be2',
'超前、锚杆': '#F14864',
'初支喷砼': '#9A6FF6',
// '空闲': '#ccc',
};
const gxstatusComputedColor = computed(() => {
return (param: any) => {
if (param == "未开始") return "red";
if (param == "进行中") return "orange";
if (param == "已完成") return "#67c23a";
if (param == "已跳过") return "#ccc";
return "#000000";
};
});
const gxComputeddatteNum = computed(() => {
return (param: any) => {
if (param < 10) return getTickLeft(param - 0.8);
else if (param >= 10 && param < 20) return getTickLeft(param - 1);
else if (param >= 20 && param < 30) return getTickLeft(param - 1.2);
return getTickLeft(param - 1.2)
};
});
const lanesTotalHeight = 45 + (laneOrder.length - 1) * 25; // 每车道10px,间距25px
const getLaneTop = (idx: number) => (idx + 1) * 25; // 依次间距25px
const colorFor = (name: string) => colorMap[name] || '#999';
// 图例:根据 laneOrder 生成名称与颜色
const legendItems = computed(() => laneOrder.map((name) => ({ name, color: colorFor(name) })));
const getLaneSegments = (item: any, laneName: string) => {
const list = (item.child || []).filter((p: any) => p && p.nodeName === laneName);
if (laneName === '空闲') {
const usedMs = (item.child || []).reduce((sum: number, p: any) => sum + parseDurationToMs(p?.processWorkTime), 0);
const totalMs = Number(item.loopUnitRecordTime) || 0;
const idleMs = Math.max(0, totalMs - usedMs);
if (idleMs > 0) list.push({ nodeName: '空闲', processWorkTime: idleMs });
}
return list;
};
// 计算段在 X 轴上的 left/width 百分比;跨午夜时按 24h 回绕
const getLeftWidthPercent = (startMsOffset: number, durationMs: number) => {
const totalMs = axisHours.value * 3600000;
if (!totalMs) return { left: '0%', width: '0%' };
const leftPct = (Math.max(0, startMsOffset) / totalMs) * 100;
const widthPct = (Math.max(0, durationMs) / totalMs) * 100;
return { left: `{leftPct}%\`, width: \`{widthPct}%` };
};
// 将十六进制颜色转换为 rgba,透明度 0.8
const hexToRgba = (hex: string, alpha = 0.7) => {
const h = hex.replace('#', '');
const bigint = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba({r}, {g}, {b}, {alpha})`;
};
// 组合段样式:颜色透明0.8+边框1px,位置由 startTimeStr/endTimeStr 决定
// 计算段的起点偏移(毫秒):按记录顺序累计之前段的用时,实现"后段从前段结束处开始"
const getStartOffsetMs = (item: any, seg: any): number => {
const arr = (item?.child || []) as any[];
let sum = 0;
for (let i = 0; i < arr.length; i++) {
const it = arr[i];
if (it === seg || (
it?.nodeName === seg?.nodeName &&
it?.startDate === seg?.startDate &&
it?.endDate === seg?.endDate &&
it?.processWorkTime === seg?.processWorkTime
)) {
break;
}
sum += parseDurationToMs(it?.processWorkTime);
}
return sum;
};
const getSegmentStyle = (p: any, item: any) => {
const axisTotalMs = axisHours.value * 3600000;
if (!axisTotalMs) {
const baseColor0 = colorFor(p?.nodeName || '');
const bg0 = hexToRgba(baseColor0, 0.7);
return {
position: 'absolute',
left: '0%',
width: '0%',
background: bg0,
border: `1px solid ${baseColor0}`,
} as any;
}
const durationMs = Math.max(0, parseDurationToMs(p?.processWorkTime));
const startOffsetMs = Math.max(0, getStartOffsetMs(item, p));
const { left, width } = getLeftWidthPercent(startOffsetMs, durationMs);
const baseColor = colorFor(p?.nodeName || '');
const bg = hexToRgba(baseColor, 0.7);
return {
position: 'absolute',
left,
width,
background: bg,
border: `1px solid ${baseColor}`,
} as any;
};
// 悬浮提示:展示到秒
const ensureSeconds = (v: any) => {
if (v == null) return '--';
if (typeof v === 'number') return dayjs(v).format('YYYY-M-D H:mm:ss');
if (typeof v === 'string') {
if (/\d{1,2}:\d{2}:\d{2}$/.test(v)) return v;
if (/\d{1,2}:\d{2}$/.test(v)) return v + ':00';
const d = dayjs(v);
if (d.isValid()) return d.format('YYYY-M-D H:mm:ss');
return v;
}
return String(v);
};
const buildTooltipText = (p: any) => {
const name = p?.nodeName || '--';
const duration = String(p?.processWorkTime ?? '--');
const s = ensureSeconds(p?.startDate);
const e = ensureSeconds(p?.endDate);
const statusText = gxstatusComputed.value(p?.nodeStatus ?? '0');
// 三行展示:第一行为"类型+用时",第二行为"开始时间",第三行为"结束时间"
return `{name} {duration}\n开始时间:{s}\\n结束时间:{e}\n状态:${statusText}`;
};
const onSegEnter = (ev: MouseEvent, p: any, item?: any) => {
const box = tableBoxRef.value;
if (!box) return;
const rect = box.getBoundingClientRect();
const name = p?.nodeName || '--';
const duration = String(p?.processWorkTime ?? '--');
const s = ensureSeconds(p?.startDate);
const e = ensureSeconds(p?.endDate);
const statusText = gxstatusComputed.value((item?.nodeStatus ?? p?.nodeStatus ?? '0'));
const statusColor = gxstatusComputedColor.value(statusText);
const typeColor = colorFor(p?.nodeName || '');
hoverTip.value = {
show: true,
text: buildTooltipText(p),
x: ev.clientX - rect.left + 12,
y: ev.clientY - rect.top + 12,
statusText,
statusColor,
name,
duration,
start: s,
end: e,
typeColor,
};
};
const onSegLeave = () => {
hoverTip.value.show = false;
};
// 滚动与分页
const handleScroll = (e: Event) => {
const el = e.target as HTMLElement;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
if (nearBottom) {
loadMore();
}
};
const loadMore = async () => {
if (isLoadingMore.value || !hasMore.value) return;
isLoadingMore.value = true;
// 使用 queryForm.page 加一实现滚动加载分页
queryForm.page = (queryForm.page || 1) + 1;
await getTestData(true);
isLoadingMore.value = false;
};
// 暴露方法
const init = async () => {
dialogVisible.value = true;
// 重置分页与数据
queryForm.page = 1;
hasMore.value = true;
tableData.value = [];
await getTestData(false);
await nextTick();
// 绑定滚动事件
if (scrollBoxRef.value) {
scrollBoxRef.value.addEventListener('scroll', handleScroll);
}
};
watch(() => props.processId, () => {
getTestData();
});
const handleClose = () => {
dialogVisible.value = false;
emit("on-success-detail");
if (myChart) {
myChart.dispose();
myChart = null;
}
if (scrollBoxRef.value) {
scrollBoxRef.value.removeEventListener('scroll', handleScroll);
}
};
defineExpose({ init });
onMounted(() => {
window.addEventListener('resize', () => {
if (myChart) myChart.resize();
});
if (scrollBoxRef.value) {
scrollBoxRef.value.addEventListener('scroll', handleScroll);
}
});
onBeforeUnmount(() => {
if (myChart) {
myChart.dispose();
myChart = null;
}
if (scrollBoxRef.value) {
scrollBoxRef.value.removeEventListener('scroll', handleScroll);
}
});
</script>
<style lang="scss" scoped>
.closeClassgxxs {
width: 35px;
height: 35px;
position: absolute;
top: 20px;
right: 20px;
background: linear-gradient(135deg,
rgba(255, 77, 79, 0.8) 0%,
rgba(255, 123, 123, 0.8) 50%,
rgba(255, 77, 79, 0.8) 100%);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 0 15px rgba(255, 77, 79, 0.4);
display: flex;
align-items: center;
justify-content: center;
&::before {
content: '×';
font-size: 24px;
color: #fff;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
line-height: 1;
}
&::after {
content: '';
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
border-radius: 50%;
border: 2px solid rgba(255, 77, 79, 0.6);
animation: close-btn-pulse 2s ease-in-out infinite;
pointer-events: none;
}
&:hover {
background: linear-gradient(135deg,
rgba(255, 77, 79, 1) 0%,
rgba(255, 123, 123, 1) 50%,
rgba(255, 77, 79, 1) 100%);
box-shadow: 0 0 20px rgba(255, 77, 79, 0.6);
transform: scale(1.1);
&::before {
text-shadow: 0 0 15px rgba(255, 255, 255, 1);
}
}
}
.topClassel {
width: 100%;
height: 50px;
display: flex;
justify-content: space-between;
.title {
width: 30%;
height: 70%;
font-weight: 700;
font-style: normal;
font-size: 18px;
// color: #fff;
border-image-slice: 1;
}
}
// 表格容器样式覆盖
.tableBox {
max-height: 80vh; // 最大高度为视口高度的80%
overflow: hidden; // 防止内容溢出
position: relative;
display: flex;
flex-direction: column;
:deep(.el-range-input) {
color: #606266;
}
.legendtype {
height: 30px;
width: 100%;
}
.table-pagination-box {
// height: 100%;
height: 70vh;
display: flex;
flex-direction: column;
// gap: 8px;
gap: 1px;
overflow-y: auto;
/* 轴样式移至外层 */
.axis-tick {
position: relative;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #9fb3c8;
font-size: 12px;
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.axis-tick:last-child {
border-right: none;
}
.axis-label {
transform: translateY(0);
}
.loop-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
// border-radius: 8px;
// background: rgba(12, 23, 43, 0.25);
// background: rgba(55, 79, 105);
background: #133573;
height: 290px;
}
.row-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.left-info {
display: flex;
flex-direction: column;
gap: 6px;
color: #e6effa;
}
.loop-name {
font-weight: 600;
font-size: 14px;
}
.stats {
// color: #9fb3c8;
color: #fff;
font-size: 13px;
.left-info-span {
color: #9fb3c8;
}
}
.stats .spacer {
margin: 0 6px;
color: #576b85;
}
.bar-area {
width: 100%;
}
.bar-bg {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 6px;
background: rgba(34, 56, 92, 0.25);
border: 1px solid rgba(255, 255, 255, 0.12);
/* 刻度向上虚线分割 */
.gridline {
position: absolute;
top: 0;
bottom: 0;
width: 0;
// border-left: 1px dashed rgba(255, 255, 255, 0.22);
border-left: 1px dashed rgb(204, 204, 204, 0.4);
pointer-events: none;
}
}
.lane {
position: absolute;
left: 0;
right: 0;
height: 10px;
display: flex;
}
.segment {
position: relative;
height: 100%;
display: flex;
align-items: center;
padding: 0 4px;
white-space: nowrap;
background: #5E92F7; // 默认值,具体颜色由内联样式覆盖
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.segment:nth-child(odd) {
/* 保留分段分隔线 */
}
.seg-label {
position: absolute;
bottom: 100%;
transform: translateY(-2px);
left: 0;
font-size: 11px;
color: #ffffff;
font-weight: 600;
pointer-events: none;
white-space: nowrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.right-info {
color: #9fb3c8;
font-size: 13px;
text-align: right;
}
.right-info .pile {
margin-left: 6px;
color: #e6effa;
}
}
/* 外层固定底部 X 轴样式 */
>.axis {
position: relative;
display: flex;
align-items: center;
width: 100%;
background: rgba(12, 23, 43, 0.6);
// border: 1px solid rgba(255, 255, 255, 0.18);
// border-right: 1px solid rgba(255, 255, 255, 0.12);
// border-radius: 6px;
overflow: hidden;
box-sizing: border-box;
// margin-top: 8px;
}
>.axis .axis-tick {
position: relative;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #e6effa;
font-size: 12px;
// border-right: 1px solid rgba(255, 255, 255, 0.12);
}
>.axis .axis-tick:last-child {
border-right: none;
}
>.axis .axis-label {
position: absolute;
bottom: 4px;
transform: translateX(-50%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
color: #fff
}
>.axis .axis-labels {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 100%;
pointer-events: none;
}
/* 悬浮提示 */
.hover-tip {
position: absolute;
max-width: 360px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(20, 30, 50, 0.95);
color: #ffffff;
font-size: 12px;
line-height: 1.4;
white-space: pre-line;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.12);
pointer-events: none;
z-index: 1000;
}
.hover-tip .tip-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
border: 1px solid rgba(255, 255, 255, 0.3);
vertical-align: middle;
}
/* 图例样式 */
.legendtype {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 6px 10px;
margin-bottom: 6px;
// color: #e6effa;
color: black;
font-size: 14px;
width: 100%;
min-height: 30px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 13px;
height: 13px;
border-radius: 50%;
display: inline-block;
border: 1px solid rgba(255, 255, 255, 0.5);
// box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.legend-text {
line-height: 1;
}
}
</style>
<style lang="scss">
.eldialoggxxs {
.el-dialog__header {
padding-bottom: 0px !important
}
}
</style>