vue3实现甘特图,把控项目风险

设计稿
image
代码如下

<template>
    <div class="gantt">
        <div class="scroll">
            <div class="inner" :style="{ width: timelineWidth + 'px' }">
                <!-- sticky 日历头 -->
                <div class="header">
                    <div class="day" v-for="dItem in days" :key="dItem.key" :class="{ today: dItem.isToday }"
                        :style="{ width: props.dayWidth + 'px' }">
                        <div class="mmdd">{{ dItem.mmdd }}</div>
                        <div class="dow">{{ dItem.dow }}</div>
                    </div>

                    <!-- markLines -->
                    <div v-for="m in markLineViews" :key="m.key" class="mark-line" :style="{ left: m.left + 'px' }">
                        <span>{{ m.name }}</span>
                    </div>
                </div>

                <!-- body -->
                <div class="body">
                    <!-- grid -->
                    <div class="grid">
                        <div v-for="dItem in days" :key="dItem.key" class="grid-day" :class="{ today: dItem.isToday }"
                            :style="{ width: props.dayWidth + 'px' }" />
                        <div v-for="m in markLineViews" :key="m.key" class="mark-line" :style="{ left: m.left + 'px' }">
                        </div>
                    </div>

                    <div class="rows">
                        <div class="row" v-for="t in tasks" :key="t.user + '|' + t.title"
                            :style="{ height: props.rowHeight + 'px' }">
                            <div class="bar" :style="barStyle(t)">
                                <div class="seg task full"></div>
                                <div class="text">{{ t.user }} / {{ t.title }}
                                </div>
                            </div>
                        </div>

                        <div v-if="tasks.length === 0" class="empty">
                            无任务
                        </div>
                    </div>
                </div>

                <div class="footerSpace"></div>
            </div>
        </div>
    </div>
</template>

<script setup>
import { computed } from "vue"
import dayjs from "dayjs"

const props = defineProps({
    tasks: { type: Array, required: true },
    taskCycle: { type: Array, required: true },
    markLines: { type: Array, default: () => [] },
    dayWidth: { type: String, default: 36 },
    rowHeight: { type: String, default: 36 }
})


const d = (s) => dayjs(s).startOf("day")

const cycleStart = computed(() => d(props.taskCycle[0]))
const cycleEnd = computed(() => d(props.taskCycle[1]))
const totalDays = computed(() => cycleEnd.value.diff(cycleStart.value, "day") + 1)
const timelineWidth = computed(() => totalDays.value * props.dayWidth)

const todayKey = dayjs().format("YYYY-MM-DD")

const days = computed(() => {
    const labels = ["日", "一", "二", "三", "四", "五", "六"]
    return Array.from({ length: totalDays.value }, (_, i) => {
        const cur = cycleStart.value.add(i, "day")
        const key = cur.format("YYYY-MM-DD")
        return {
            key,
            mmdd: cur.format("MM/DD"),
            dow: labels[cur.day()],
            isToday: key === todayKey
        }
    })
})


const range = (start, end) => {
    const l = Math.max(0, start.diff(cycleStart.value, "day"))
    const r = Math.min(totalDays.value - 1, end.diff(cycleStart.value, "day"))
    return { l, r }
}

const barStyle = (t) => {
    let start, end
    start = d(t.startDate)
    end = d(t.endDate)


    const { l, r } = range(start, end)
    return {
        left: (l * props.dayWidth) + "px",
        width: ((r - l + 1) * props.dayWidth) + "px",
        height: (props.rowHeight - 16) + "px",
        top: "8px"
    }
}

const markLineViews = computed(() => {
    return props.markLines
        .map(m => {
            const leftDays = d(m.date).diff(cycleStart.value, "day")
            return {
                key: `${m.name}-${m.date}`,
                name: m.name,
                left: leftDays * props.dayWidth,
                inRange: leftDays >= 0 && leftDays <= totalDays.value - 1
            }
        })
        .filter(x => x.inRange)
})
</script>

<style lang="scss" scoped>
.gantt {
    height: 100%;
    width: 100%;
    border: 1px solid #e6e6e6;
    border-radius: 10px;
    overflow: hidden;
    background: #fff;
    display: flex;
}

.scroll {
    flex: 1;
    overflow: auto;
}

.inner {
    position: relative;
    min-height: 100%;
}

.header {
    position: sticky;
    top: 0;
    z-index: 5;
    height: 50px;
    background: #fafafa;
    border-bottom: 1px solid #eee;
    display: flex;
}

.day {
    box-sizing: border-box;
    border-right: 1px solid #eee;
    text-align: center;
    font-size: 12px;
    padding-top: 6px;
}

.mmdd {
    font-weight: 700;
    line-height: 16px;
}

.dow {
    color: #666;
    line-height: 16px;
}

.day.today {
    background: rgba(255, 230, 150, 0.55);
}

.body {
    position: relative;
}

.grid {
    position: absolute;
    inset: 0;
    display: flex;
    pointer-events: none;
    z-index: 0;
}

.grid-day {
    border-right: 1px solid #f0f0f0;
}

.grid-day.today {
    background: rgba(255, 230, 150, 0.35);
}

.rows {
    position: relative;
    z-index: 1;

    .row {
        position: relative;
        border-bottom: 1px solid #eee;
    }
}



.bar {
    position: absolute;
    border-radius: 12px;
    border: 1px solid rgba(0, 0, 0, .10);
}

.seg {
    position: absolute;
    top: 0;
    bottom: 0;

    &.task {
        background: rgba(59, 130, 246, .80);
    }


    &.full {
        left: 0;
        width: 100%;
    }
}

.text {
    position: relative;
    z-index: 2;
    width: 200%;
    padding: 0 10px;
    color: #000;
    text-align: left;
    font-size: 12px;
}

.mark-line {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 0;
    border-left: 2px dashed rgba(239, 68, 68, 0.7);
    pointer-events: none;
}

.mark-line span {
    position: absolute;
    top: 6px;
    left: 6px;
    background: rgba(255, 255, 255, .92);
    border: 1px solid rgba(239, 68, 68, .25);
    color: rgba(239, 68, 68, .95);
    font-size: 12px;
    padding: 2px 6px;
    border-radius: 999px;
    white-space: nowrap;
}

.empty {
    padding: 24px 12px;
    color: #999;
}

.footerSpace {
    height: 10px;
}
</style>

效果如下图
image

posted @ 2026-01-22 23:05  guest_x  阅读(0)  评论(0)    收藏  举报