vue甘特图

本文介绍了一个基于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>

相关推荐
进击的野人2 小时前
CSS 定位详解:从文档流到五种定位方式
前端·css
李瑞丰_liruifengv2 小时前
使用 Claude Agent SDK 开发一个 Agent 原来这么简单
前端·javascript·agent
残冬醉离殇2 小时前
《手撕类Vue2的响应式核心思想:我的学习心路历程》
前端·vue.js
有意义2 小时前
为什么说数组是 JavaScript 开发者必须精通的数据结构?
前端·数据结构·算法
百***41662 小时前
Go-Gin Web 框架完整教程
前端·golang·gin
lichong9512 小时前
【macOS 版】Android studio jdk 1.8 gradle 一键打包成 release 包的脚本
android·java·前端·macos·android studio·大前端·大前端++
用户12039112947262 小时前
深入JavaScript数组:从内存模型到遍历性能,打造高性能代码的基石
javascript
驯狼小羊羔2 小时前
学习随笔-http和https有何区别
前端·javascript·学习·http·https
草明2 小时前
Chrome HSTS(HTTP Strict Transport Security)
前端·chrome·http