You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1137 lines
36 KiB

<template>
<div class="gantt-shell">
<!-- 主甘特图面板单层可拖动/可缩放 -->
<div
v-if="dialogVisible && !isMinimized"
class="gantt-panel"
:style="panelStyle"
>
<div class="gantt-dialog-title" @mousedown.prevent="onPanelDragStart">
<span class="gantt-dialog-title-text">甘特图</span>
<span class="gantt-dialog-title-actions" @mousedown.stop>
<i class="gantt-dialog-header-icon el-icon-minus" title="最小化" @click="minimize"></i>
<i class="gantt-dialog-header-icon el-icon-close" title="关闭" @click="onClose"></i>
</span>
</div>
<div class="gantt-dialog-body" ref="ganttBody">
<!-- 固定标题栏工具栏图例 不随内框缩放 -->
<div class="gantt-toolbar">
<div class="toolbar-left">
<span class="k-time-range">K 时范围{{ kTimeRangeText }}</span>
</div>
<div class="toolbar-right">
<el-button size="small" @click="refreshData">刷新</el-button>
<el-dropdown trigger="click" @command="handleExport">
<el-button size="small" type="primary">
导出<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown" class="gantt-export-dropdown-menu">
<el-dropdown-item command="png">导出 PNG</el-dropdown-item>
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<div class="gantt-scroll-container" ref="ganttScrollContainer">
<div class="gantt-main">
<div class="gantt-sidebar">
<div class="sidebar-header">任务名称</div>
<div class="sidebar-content">
<div
v-for="(item, index) in displayBars"
:key="item.id"
class="sidebar-item"
:class="{ 'sidebar-item-last': index === displayBars.length - 1 }"
>
<div class="item-icon" :style="{ backgroundColor: item.color }"></div>
<div class="item-name">{{ item.name }}</div>
</div>
</div>
</div>
<div class="gantt-timeline-wrap" :style="{ '--hour-width': hourWidth + 'px' }">
<!-- 包裹层横向滚动在 timeline-scroll纵向滚动在 timeline-content避免右侧竖条被裁切 -->
<div class="timeline-scroll-clip">
<div class="timeline-scroll" ref="timelineScroll">
<div class="timeline-header" :style="{ width: timelineWidth + 'px' }">
<div
v-for="(tick, index) in timelineTicks"
:key="index"
class="timeline-tick"
:style="{
left: tick.left + 'px',
width: tick.width + 'px'
}"
>
<span class="time-label">{{ tick.label }}</span>
</div>
</div>
<div class="timeline-content">
<div
v-for="(item, index) in displayBars"
:key="item.id"
class="timeline-row"
:class="{ 'timeline-row-last': index === displayBars.length - 1 }"
>
<el-tooltip
effect="dark"
placement="top"
:append-to-body="true"
popper-class="gantt-bar-tooltip"
:open-delay="150"
:content="buildTooltipText(item)"
>
<!-- 外层占满色块区域避免 tooltip absolute 子元素时触发区域为 0 -->
<div
class="timeline-bar-host"
:style="{
position: 'absolute',
left: item.startPx + 'px',
top: '6px',
width: item.widthPx + 'px',
height: '32px',
zIndex: 1
}"
:title="ganttBarTitleOneLine(item)"
>
<div
class="timeline-bar"
:class="{ 'timeline-bar-editable': isBarEditable(item) }"
:style="{
left: '0',
width: '100%',
backgroundColor: item.color
}"
>
<span
v-if="isBarEditable(item)"
class="bar-resize-handle bar-resize-handle-left"
title="拖动调整开始时间"
@mousedown.prevent.stop="onBarResizeStart($event, item, 'start')"
></span>
<span
v-if="isBarEditable(item)"
class="bar-resize-handle bar-resize-handle-right"
title="拖动调整结束时间"
@mousedown.prevent.stop="onBarResizeStart($event, item, 'end')"
></span>
<div class="bar-inner">
<span class="bar-text bar-text-name">
{{ item.name }}
</span>
<span class="bar-text bar-text-time">
{{ item.startKTime }} {{ item.endKTime }}
</span>
</div>
</div>
</div>
</el-tooltip>
</div>
</div>
</div>
</div>
<!-- 横向滚动由 .timeline-scroll 自带原生滚动条完成不再使用自定义灰条 -->
</div>
</div>
</div>
<!-- 固定图例不随内框缩放 -->
<div class="gantt-legend">
<span class="legend-title">图例</span>
<span class="legend-item">
<span class="legend-color legend-color-route"></span>航线
</span>
<span class="legend-item">
<span class="legend-color legend-color-missile"></span>导弹发射
</span>
<span class="legend-item">
<span class="legend-color legend-color-hold"></span>盘旋
</span>
</div>
</div>
<div
class="gantt-panel-resize-handle"
title="拖拽调整窗口大小"
@mousedown.prevent.stop="onPanelResizeStart"
></div>
</div>
<!-- 底部最小化状态条 -->
<transition name="fade">
<div
v-if="isMinimized"
class="gantt-minibar"
@click="restore"
>
<div class="minibar-left">
<span class="minibar-title">甘特图</span>
<span class="minibar-desc">
任务数{{ displayBars.length }}时间范围{{ kTimeRangeText }}
</span>
</div>
<div class="minibar-right">
<span class="minibar-hint">点击还原</span>
</div>
</div>
</transition>
</div>
</template>
<script>
import { getMissileParams } from '@/api/system/routes';
const HOUR_WIDTH = 100; // 每小时宽度(像素)
function minutesToKTime(minutes) {
const m = Number(minutes);
if (!Number.isFinite(m)) return 'K+00:00';
const sign = m >= 0 ? '+' : '-';
const abs = Math.abs(m);
// 使用“先把总分钟四舍五入,再做进位”避免出现 min=60 但小时不进位的问题(例如 K+03:60)
const totalMin = Math.round(abs);
const h = Math.floor(totalMin / 60);
const min = totalMin % 60;
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}
function formatDurationText(startMinutes, endMinutes) {
const s = Number(startMinutes);
const e = Number(endMinutes);
if (!Number.isFinite(s) || !Number.isFinite(e)) return '--';
const deltaMin = Math.max(0, e - s);
const totalSec = Math.round(deltaMin * 60);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const sec = totalSec % 60;
if (h > 0) return `${h}小时${m}${sec}`;
if (m > 0) return `${m}${sec}`;
return `${sec}`;
}
export default {
name: 'GanttDrawer',
props: {
value: { type: Boolean, default: false },
timeRange: { type: Object, default: () => ({ minMinutes: 0, maxMinutes: 120 }) },
routeBars: { type: Array, default: () => [] },
holdBars: { type: Array, default: () => [] },
currentRoomId: { type: [String, Number], default: null },
routes: { type: Array, default: () => [] },
activeRouteIds: { type: Array, default: () => [] }
},
data() {
return {
missileBars: [],
exporting: false,
isMinimized: false,
displayBars: [],
hourWidth: HOUR_WIDTH,
panelLeft: null,
panelTop: 60,
panelWidth: 1200,
panelHeight: 800,
panelMinWidth: 860,
panelMinHeight: 460,
isPanelDragging: false,
panelDragOffsetX: 0,
panelDragOffsetY: 0,
isPanelResizing: false,
panelResizeStartX: 0,
panelResizeStartY: 0,
panelResizeStartW: 0,
panelResizeStartH: 0,
barResizeState: null
};
},
computed: {
dialogVisible: {
get() { return this.value; },
set(v) { this.$emit('input', v); }
},
panelStyle() {
const defaultLeft = Math.max(0, (window.innerWidth - this.panelWidth) / 2 - 20);
const left = this.panelLeft == null ? defaultLeft : this.panelLeft;
const top = this.panelTop == null ? 60 : this.panelTop;
return {
left: `${Math.max(0, left)}px`,
top: `${Math.max(0, top)}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
};
},
minMinutes() {
return this.timeRange && Number.isFinite(this.timeRange.minMinutes) ? this.timeRange.minMinutes : 0;
},
maxMinutes() {
return this.timeRange && Number.isFinite(this.timeRange.maxMinutes) ? this.timeRange.maxMinutes : 120;
},
spanMinutes() {
const s = this.maxMinutes - this.minMinutes;
return s > 0 ? s : 120;
},
kTimeRangeText() {
return `${minutesToKTime(this.minMinutes)}${minutesToKTime(this.maxMinutes)}`;
},
// 时间轴总宽度(像素):按照分钟 / 60 * hourWidth 计算
timelineWidth() {
return (this.spanMinutes / 60) * this.hourWidth;
},
// 表头刻度:按 2 小时一个主刻度 K+HH:00,时间轴向上取整到整 2 小时(例如 13:32 -> 14:00)
timelineTicks() {
const ticks = [];
const startHour = Math.floor(this.minMinutes / 60);
const rawEndHour = Math.ceil(this.maxMinutes / 60);
const endHour = rawEndHour % 2 === 0 ? rawEndHour : rawEndHour + 1;
const stepHour = 2;
const hw = this.hourWidth;
for (let h = startHour; h <= endHour; h += stepHour) {
const minutes = h * 60;
ticks.push({
label: minutesToKTime(minutes),
left: ((minutes - this.minMinutes) / 60) * hw,
width: hw * 2
});
}
return ticks;
}
},
watch: {
value(v) {
if (v) {
this.isMinimized = false;
this.ensurePanelInViewport();
this.loadMissiles();
this.rebuildGanttData();
}
},
timeRange: {
handler() {
this.rebuildGanttData();
},
deep: true
},
routeBars: {
handler() {
this.rebuildGanttData();
},
deep: true
},
holdBars: {
handler() {
this.rebuildGanttData();
},
deep: true
},
missileBars: {
handler() {
this.rebuildGanttData();
},
deep: true
},
routes: {
handler() {
if (this.dialogVisible) {
this.loadMissiles();
}
},
deep: true
},
activeRouteIds() {
if (this.dialogVisible) {
this.loadMissiles();
}
}
},
methods: {
minimize() {
this.isMinimized = true;
this.dialogVisible = false;
},
restore() {
this.isMinimized = false;
this.dialogVisible = true;
},
onClose() {
this.$emit('input', false);
},
ensurePanelInViewport() {
if (this.panelLeft == null) return;
const maxLeft = Math.max(0, window.innerWidth - this.panelWidth);
const maxTop = Math.max(0, window.innerHeight - this.panelHeight);
this.panelLeft = Math.max(0, Math.min(maxLeft, this.panelLeft));
this.panelTop = Math.max(0, Math.min(maxTop, this.panelTop));
},
onPanelDragStart(e) {
if (this.isPanelResizing) return;
this.isPanelDragging = true;
const currentLeft = this.panelLeft == null ? Math.max(0, (window.innerWidth - this.panelWidth) / 2 - 20) : this.panelLeft;
const currentTop = this.panelTop == null ? 60 : this.panelTop;
this.panelDragOffsetX = e.clientX - currentLeft;
this.panelDragOffsetY = e.clientY - currentTop;
document.addEventListener('mousemove', this.onPanelDragMove);
document.addEventListener('mouseup', this.onPanelDragEnd);
},
onPanelDragMove(e) {
if (!this.isPanelDragging) return;
e.preventDefault();
const maxLeft = Math.max(0, window.innerWidth - this.panelWidth);
const maxTop = Math.max(0, window.innerHeight - this.panelHeight);
const left = e.clientX - this.panelDragOffsetX;
const top = e.clientY - this.panelDragOffsetY;
this.panelLeft = Math.max(0, Math.min(maxLeft, left));
this.panelTop = Math.max(0, Math.min(maxTop, top));
},
onPanelDragEnd() {
this.isPanelDragging = false;
document.removeEventListener('mousemove', this.onPanelDragMove);
document.removeEventListener('mouseup', this.onPanelDragEnd);
},
onPanelResizeStart(e) {
if (this.isPanelDragging) return;
this.isPanelResizing = true;
this.panelResizeStartX = e.clientX;
this.panelResizeStartY = e.clientY;
this.panelResizeStartW = this.panelWidth;
this.panelResizeStartH = this.panelHeight;
document.addEventListener('mousemove', this.onPanelResizeMove);
document.addEventListener('mouseup', this.onPanelResizeEnd);
},
onPanelResizeMove(e) {
if (!this.isPanelResizing) return;
e.preventDefault();
const dx = e.clientX - this.panelResizeStartX;
const dy = e.clientY - this.panelResizeStartY;
this.panelWidth = Math.max(this.panelMinWidth, Math.min(window.innerWidth - 20, this.panelResizeStartW + dx));
this.panelHeight = Math.max(this.panelMinHeight, Math.min(window.innerHeight - 20, this.panelResizeStartH + dy));
this.ensurePanelInViewport();
},
onPanelResizeEnd() {
this.isPanelResizing = false;
document.removeEventListener('mousemove', this.onPanelResizeMove);
document.removeEventListener('mouseup', this.onPanelResizeEnd);
},
rebuildGanttData() {
const list = [];
const span = this.spanMinutes || 1;
const hw = this.hourWidth;
(this.routeBars || []).forEach(r => {
const durationMin = Math.max(0, r.endMinutes - r.startMinutes);
const startPx = ((r.startMinutes - this.minMinutes) / 60) * hw;
const widthPx = Math.max(40, (durationMin / 60) * hw);
const endLabelMinutes = Math.min(r.endMinutes, this.maxMinutes);
list.push({
id: r.id,
name: r.name,
type: 'route',
color: r.color || '#409EFF',
startMinutes: r.startMinutes,
endMinutes: r.endMinutes,
actualStartKTime: minutesToKTime(r.startMinutes),
actualEndKTime: minutesToKTime(r.endMinutes),
startKTime: minutesToKTime(r.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
widthPx
});
});
(this.holdBars || []).forEach(h => {
const durationMin = Math.max(0, h.endMinutes - h.startMinutes);
const startPx = ((h.startMinutes - this.minMinutes) / 60) * hw;
const widthPx = Math.max(40, (durationMin / 60) * hw);
const endLabelMinutes = Math.min(h.endMinutes, this.maxMinutes);
list.push({
id: h.id,
name: h.name,
type: 'hold',
color: h.color || '#E6A23C',
startMinutes: h.startMinutes,
endMinutes: h.endMinutes,
actualStartKTime: minutesToKTime(h.startMinutes),
actualEndKTime: minutesToKTime(h.endMinutes),
startKTime: minutesToKTime(h.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
widthPx
});
});
(this.missileBars || []).forEach(m => {
const durationMin = Math.max(1, m.endMinutes - m.startMinutes);
const startPx = ((m.startMinutes - this.minMinutes) / 60) * hw;
const widthPx = Math.max(40, (durationMin / 60) * hw);
const endLabelMinutes = Math.min(m.endMinutes, this.maxMinutes);
list.push({
id: m.id,
name: m.name,
type: 'missile',
color: m.color || '#F56C6C',
startMinutes: m.startMinutes,
endMinutes: m.endMinutes,
actualStartKTime: minutesToKTime(m.startMinutes),
actualEndKTime: minutesToKTime(m.endMinutes),
startKTime: minutesToKTime(m.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
widthPx
});
});
this.displayBars = list.sort((a, b) => a.startMinutes - b.startMinutes);
},
isBarEditable(item) {
return item && item.type === 'route';
},
onBarResizeStart(e, item, edge) {
if (!this.isBarEditable(item)) return;
this.barResizeState = {
id: item.id,
edge,
startX: e.clientX,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes
};
document.addEventListener('mousemove', this.onBarResizeMove);
document.addEventListener('mouseup', this.onBarResizeEnd);
},
onBarResizeMove(e) {
if (!this.barResizeState) return;
e.preventDefault();
const state = this.barResizeState;
const deltaMinutes = ((e.clientX - state.startX) / this.hourWidth) * 60;
const roundedDelta = Math.round(deltaMinutes);
const minDuration = 1;
const idx = this.displayBars.findIndex(b => b.id === state.id);
if (idx < 0) return;
const target = this.displayBars[idx];
let nextStart = state.startMinutes;
let nextEnd = state.endMinutes;
if (state.edge === 'start') {
nextStart = Math.min(state.endMinutes - minDuration, state.startMinutes + roundedDelta);
} else {
nextEnd = Math.max(state.startMinutes + minDuration, state.endMinutes + roundedDelta);
}
this.applyBarMinutes(target, nextStart, nextEnd);
},
onBarResizeEnd() {
if (!this.barResizeState) return;
const state = this.barResizeState;
const target = this.displayBars.find(b => b.id === state.id);
if (target) {
this.$emit('bar-time-change', {
id: target.id,
type: target.type,
name: target.name,
startMinutes: target.startMinutes,
endMinutes: target.endMinutes
});
}
this.barResizeState = null;
document.removeEventListener('mousemove', this.onBarResizeMove);
document.removeEventListener('mouseup', this.onBarResizeEnd);
},
applyBarMinutes(bar, startMinutes, endMinutes) {
const durationMin = Math.max(0, endMinutes - startMinutes);
bar.startMinutes = startMinutes;
bar.endMinutes = endMinutes;
bar.startKTime = minutesToKTime(startMinutes);
bar.endKTime = minutesToKTime(Math.min(endMinutes, this.maxMinutes));
bar.actualStartKTime = minutesToKTime(startMinutes);
bar.actualEndKTime = minutesToKTime(endMinutes);
bar.startPx = ((startMinutes - this.minMinutes) / 60) * this.hourWidth;
bar.widthPx = Math.max(40, (durationMin / 60) * this.hourWidth);
},
buildTooltipText(item) {
const start = item.actualStartKTime || item.startKTime;
const end = item.actualEndKTime || item.endKTime;
const duration = formatDurationText(item.startMinutes, item.endMinutes);
return `${item.name}\n时间段:${start}${end}\n时长:${duration}`;
},
/** 浏览器原生 title(单行),作后备;悬浮块区域小时也能看到 */
ganttBarTitleOneLine(item) {
const start = item.actualStartKTime || item.startKTime;
const end = item.actualEndKTime || item.endKTime;
const duration = formatDurationText(item.startMinutes, item.endMinutes);
return `${item.name} | ${start}${end} | ${duration}`;
},
refreshData() {
this.loadMissiles();
this.$message.success('已刷新');
},
loadMissiles() {
if (!this.currentRoomId || !this.activeRouteIds || this.activeRouteIds.length === 0) {
this.missileBars = [];
return;
}
const promises = this.activeRouteIds.map(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route) return Promise.resolve([]);
const rId = this.currentRoomId;
return getMissileParams({
roomId: rId,
routeId: route.id,
platformId: route.platformId != null ? route.platformId : 0
}).then(res => {
let data = res.data;
if (!data) return [];
if (!Array.isArray(data)) data = [data];
const routeLabel = route.name || String(routeId);
return data
.filter(m => m.launchTimeMinutesFromK != null)
.map((m, idx) => {
const start = Number(m.launchTimeMinutesFromK);
const distanceKm = m.distance != null ? Number(m.distance) : 1000;
const speedKmh = 1400;
const durationMinutes = (distanceKm / speedKmh) * 60;
const duration = Math.max(1, durationMinutes);
return {
id: `missile-${route.id}-${idx}`,
name: `${routeLabel}发射导弹`,
startMinutes: start,
endMinutes: start + duration,
color: '#F56C6C'
};
});
}).catch(() => []);
});
Promise.all(promises).then(arrays => {
this.missileBars = arrays.flat();
});
},
minutesToKTime,
handleExport(cmd) {
if (cmd === 'png') this.exportPng();
else if (cmd === 'pdf') this.exportPdf();
},
/** 导出用:捕获内框完整内容(含超出可视区域部分),非仅视口 */
getExportTarget() {
const container = this.$refs.ganttScrollContainer;
const content = container;
if (container) return { container, content };
return null;
},
exportPng() {
if (this.exporting) return;
this.exporting = true;
import('html2canvas').then(({ default: html2canvas }) => {
const target = this.getExportTarget();
if (!target) {
this.exporting = false;
this.$message.warning('无法获取甘特图区域');
return;
}
const { container, content } = target;
const scrollLeft = container.scrollLeft;
const scrollTop = container.scrollTop;
const origOverflow = container.style.overflow;
const origWidth = container.style.width;
const origHeight = container.style.height;
const fullW = container.scrollWidth;
const fullH = container.scrollHeight;
container.style.overflow = 'visible';
container.style.width = fullW + 'px';
container.style.height = fullH + 'px';
const captureEl = content && content.offsetHeight > 0 ? content : container;
html2canvas(captureEl, { useCORS: true, scale: 2, backgroundColor: '#fff', width: fullW, height: fullH })
.then(canvas => {
container.style.overflow = origOverflow;
container.style.width = origWidth;
container.style.height = origHeight;
container.scrollLeft = scrollLeft;
container.scrollTop = scrollTop;
const link = document.createElement('a');
link.download = `甘特图_${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
this.$message.success('PNG 已导出');
})
.catch(err => {
container.style.overflow = origOverflow;
container.style.width = origWidth;
container.style.height = origHeight;
container.scrollLeft = scrollLeft;
container.scrollTop = scrollTop;
console.error(err);
this.$message.error('导出 PNG 失败');
})
.finally(() => { this.exporting = false; });
}).catch(() => {
this.$message.error('请先安装 html2canvas');
this.exporting = false;
});
},
exportPdf() {
if (this.exporting) return;
this.exporting = true;
Promise.all([
import('html2canvas'),
import('jspdf')
]).then(([{ default: html2canvas }, { default: jsPDF }]) => {
const target = this.getExportTarget();
if (!target) {
this.exporting = false;
this.$message.warning('无法获取甘特图区域');
return;
}
const { container, content } = target;
const scrollLeft = container.scrollLeft;
const scrollTop = container.scrollTop;
const origOverflow = container.style.overflow;
const origWidth = container.style.width;
const origHeight = container.style.height;
const fullW = container.scrollWidth;
const fullH = container.scrollHeight;
container.style.overflow = 'visible';
container.style.width = fullW + 'px';
container.style.height = fullH + 'px';
const captureEl = content && content.offsetHeight > 0 ? content : container;
html2canvas(captureEl, { useCORS: true, scale: 2, backgroundColor: '#fff', width: fullW, height: fullH })
.then(canvas => {
container.style.overflow = origOverflow;
container.style.width = origWidth;
container.style.height = origHeight;
container.scrollLeft = scrollLeft;
container.scrollTop = scrollTop;
const imgData = canvas.toDataURL('image/png');
const w = canvas.width;
const h = canvas.height;
const pdf = new jsPDF({
orientation: w > h ? 'landscape' : 'portrait',
unit: 'px',
format: [w, h]
});
pdf.addImage(imgData, 'PNG', 0, 0, w, h);
pdf.save(`甘特图_${Date.now()}.pdf`);
this.$message.success('PDF 已导出');
})
.catch(err => {
container.style.overflow = origOverflow;
container.style.width = origWidth;
container.style.height = origHeight;
container.scrollLeft = scrollLeft;
container.scrollTop = scrollTop;
console.error(err);
this.$message.error('导出 PDF 失败');
})
.finally(() => { this.exporting = false; });
}).catch(() => {
this.$message.error('请先安装 html2canvas 和 jspdf');
this.exporting = false;
});
}
},
beforeDestroy() {
this.onPanelDragEnd();
this.onPanelResizeEnd();
this.onBarResizeEnd();
}
};
</script>
<style scoped>
.gantt-shell {
position: relative;
}
.gantt-panel {
position: fixed;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
border: 1px solid #e4e7ed;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12);
z-index: 2500;
overflow: hidden;
}
.gantt-dialog-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
background: #f5f7fb;
overflow: hidden;
}
.gantt-toolbar {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 10px;
}
.toolbar-left { font-size: 13px; color: #606266; }
.toolbar-right { display: flex; gap: 8px; align-items: center; }
.k-time-range { font-weight: 500; }
/* 内框:可缩放,overflow:auto 产生局部滚动条 */
.gantt-scroll-container {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
border-radius: 6px;
box-shadow: 0 0 0 1px #ebeef5;
background: #fff;
}
.gantt-main {
display: flex;
background: #fff;
overflow: hidden;
box-shadow: none;
}
.gantt-sidebar {
width: 200px;
flex-shrink: 0;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
}
.sidebar-header {
height: 40px;
display: flex;
align-items: center;
padding: 0 12px;
background: #f5f7fa;
font-size: 13px;
font-weight: 500;
color: #606266;
}
.sidebar-content { flex: 1; overflow-y: auto; }
.sidebar-item {
height: 44px;
display: flex;
align-items: center;
padding: 0 12px;
border-bottom: 1px solid #f0f0f0;
}
.item-icon {
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
}
.item-name {
font-size: 13px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gantt-timeline-wrap {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
background: #fff; /* 与主区一致,避免任何缝隙显灰 */
}
/* 包裹层:固定高度,避免子元素把右侧竖条裁切 */
.timeline-scroll-clip {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 横向仍可滚动;同时显示在同一层,避免“内外两层感” */
.timeline-scroll {
flex: 1;
min-height: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow-x: auto;
overflow-y: hidden !important;
position: relative;
border: none;
background: #fff;
scrollbar-width: none;
-ms-overflow-style: none;
}
.timeline-header {
flex-shrink: 0;
height: 40px;
position: relative;
background: #f9fafb;
border-bottom: 1px solid #f0f0f0;
}
.timeline-tick {
position: absolute;
top: 0;
bottom: 0;
border-right: 1px solid #e4e7ed;
box-sizing: border-box;
padding-left: 2px;
display: flex;
align-items: center;
}
.time-label {
font-size: 12px;
color: #606266;
font-weight: 500;
text-align: left;
}
.timeline-content {
flex: 1;
min-height: 0;
position: relative;
overflow-x: hidden;
overflow-y: hidden;
background: #fff;
border-bottom: none;
}
.timeline-row {
height: 44px;
position: relative;
border-bottom: 1px solid #f5f5f5;
background-image:
repeating-linear-gradient(
to right,
#e5e7eb 0,
#e5e7eb 1px,
transparent 1px,
transparent var(--hour-width)
),
repeating-linear-gradient(
to right,
rgba(0,0,0,0.08) 0,
rgba(0,0,0,0.08) 1px,
transparent 1px,
transparent calc(var(--hour-width) / 2)
);
background-size: var(--hour-width) 100%, calc(var(--hour-width) / 2) 100%;
}
.timeline-row:last-child,
.timeline-row.timeline-row-last {
border-bottom: none !important; /* 最后一行无底边,避免与底部滚动条之间出现“可拖细线” */
}
.sidebar-item-last {
border-bottom: none !important;
}
/* 时间轴与底部滚动条之间不增加任何边框/线,避免误以为有一条可拖的线 */
.timeline-scroll-clip {
border-bottom: none !important;
}
/* 色块外包一层,保证 tooltip 触发区域与色块一致 */
.timeline-bar-host {
pointer-events: auto;
}
.timeline-bar-host .timeline-bar {
top: 0;
left: 0;
width: 100%;
height: 100%;
min-width: 0;
}
.timeline-bar {
position: absolute;
top: 6px;
height: 32px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: default;
min-width: 80px;
box-shadow: 0 0 0 1px rgba(0,0,0,0.05);
}
.timeline-bar.timeline-bar-editable {
cursor: ew-resize;
}
.bar-resize-handle {
position: absolute;
top: 0;
width: 10px;
height: 100%;
z-index: 2;
}
.bar-resize-handle-left {
left: 0;
cursor: w-resize;
}
.bar-resize-handle-right {
right: 0;
cursor: e-resize;
}
.bar-resize-handle::after {
content: '';
position: absolute;
top: 7px;
bottom: 7px;
width: 2px;
background: rgba(255, 255, 255, 0.85);
}
.bar-resize-handle-left::after {
left: 2px;
}
.bar-resize-handle-right::after {
right: 2px;
}
.bar-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 8px;
width: 100%;
}
.bar-text {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
color: #fff;
}
.bar-text-name {
font-size: 12px;
font-weight: 500;
}
.bar-text-time {
font-size: 10px;
opacity: 0.9;
margin-top: 2px;
}
.gantt-legend {
flex-shrink: 0;
margin-top: 10px;
padding: 6px 0 2px;
border-top: none;
font-size: 12px;
line-height: 1.7;
color: #606266;
white-space: normal;
word-break: keep-all;
}
.legend-title { margin-right: 10px; color: #909399; }
.legend-item { margin-right: 14px; display: inline-flex; align-items: center; gap: 6px; }
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
display: inline-block;
}
.legend-color-route { background: #409EFF; }
.legend-color-missile { background: #F56C6C; }
.legend-color-hold { background: #E6A23C; }
.gantt-minibar {
position: fixed;
left: 16px;
right: 16px;
bottom: 8px;
height: 40px;
background: rgba(255, 255, 255, 0.96);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #606266;
cursor: pointer;
z-index: 3000;
border: 1px solid #e4e7ed;
}
.gantt-panel-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 20;
background: linear-gradient(to top left, transparent 50%, rgba(64, 158, 255, 0.28) 50%);
}
.gantt-panel-resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(64, 158, 255, 0.42) 50%);
}
.minibar-left {
display: flex;
align-items: center;
gap: 12px;
}
.minibar-title {
font-weight: 600;
color: #303133;
}
.minibar-desc {
color: #909399;
}
.minibar-right {
color: #409EFF;
font-size: 12px;
}
</style>
<style>
.gantt-dialog-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px 8px 16px;
border-bottom: 1px solid #e9edf3;
background: linear-gradient(180deg, #fafbfd 0%, #f5f8fc 100%);
cursor: move;
}
.gantt-dialog-title-text {
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: #5c6778;
letter-spacing: 0.2px;
}
.gantt-dialog-title-actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.gantt-dialog-header-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #909399;
cursor: pointer;
transition: color 0.2s;
border-radius: 4px;
border: none;
background: none;
outline: none;
}
.gantt-dialog-header-icon:hover {
color: #303133;
background: #eef2f8;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
/* 挂到 body 的 tooltip,需高于甘特面板 z-index:2500 */
.gantt-bar-tooltip {
z-index: 10050 !important;
max-width: 360px;
white-space: pre-line;
line-height: 1.45;
}
/* 下拉挂到 body 时默认 z-index≈2001,低于 .gantt-panel(2500),菜单会被面板挡住 */
.gantt-export-dropdown-menu.el-dropdown-menu {
z-index: 10060 !important;
}
</style>