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.

1048 lines
31 KiB

<template>
<div class="gantt-shell">
<!-- 主甘特图弹窗 -->
<el-dialog
v-if="!isMinimized"
:visible.sync="dialogVisible"
width="1200px"
top="5vh"
:modal="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
custom-class="gantt-dialog gantt-dialog-fixed"
@close="handleClose"
>
<div slot="title" class="gantt-dialog-title">
<span class="gantt-dialog-title-text">甘特图</span>
<span class="gantt-dialog-title-actions">
<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="restoreInnerSize" title="恢复内框默认大小">恢复大小</el-button>
<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">
<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>
<!-- 内框可缩放overflow:auto 产生局部滚动条HOUR_WIDTH 不变通过滚动查看超出部分 -->
<div
class="gantt-scroll-container"
ref="ganttScrollContainer"
:style="{ width: innerWidth + 'px', height: innerHeight + 'px' }"
>
<div class="gantt-inner-content" ref="ganttInnerContent">
<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"
:content="buildTooltipText(item)"
>
<div
class="timeline-bar"
:style="{
left: item.startPx + 'px',
width: item.widthPx + 'px',
backgroundColor: item.color
}"
>
<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>
</el-tooltip>
</div>
</div>
</div>
</div>
<!-- 横向滚动由 .timeline-scroll 自带原生滚动条完成不再使用自定义灰条 -->
</div>
</div>
</div>
<!-- 内框左上角右下角缩放手柄仅改变本容器宽高 -->
<div
class="gantt-inner-resize-handle gantt-inner-resize-handle-tl"
title="拖拽调整内框大小"
@mousedown.prevent.stop="onInnerResizeTopLeft"
></div>
<div
class="gantt-inner-resize-handle gantt-inner-resize-handle-br"
title="拖拽调整内框大小"
@mousedown.prevent.stop="onInnerResizeBottomRight"
></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>
</el-dialog>
<!-- 底部最小化状态条 -->
<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);
const h = Math.floor(abs / 60);
const min = Math.round(abs % 60);
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}
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,
// 内框可缩放尺寸(外框固定 1200*800,不随句柄改变)
innerWidth: 1168,
innerHeight: 560,
innerMinWidth: 400,
innerMinHeight: 280
};
},
computed: {
dialogVisible: {
get() { return this.value; },
set(v) { this.$emit('input', v); }
},
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.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: {
handleClose() {
// 统一处理对话框关闭逻辑
this.onClose();
},
minimize() {
this.isMinimized = true;
this.dialogVisible = false;
},
restore() {
this.isMinimized = false;
this.dialogVisible = true;
},
onClose() {
this.$emit('input', false);
},
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,
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,
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,
startKTime: minutesToKTime(m.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
widthPx
});
});
this.displayBars = list.sort((a, b) => a.startMinutes - b.startMinutes);
},
/** 内框右下角拖拽:仅改变 innerWidth / innerHeight,拖拽中实时重算布局 */
onInnerResizeBottomRight(e) {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = this.innerWidth;
const startH = this.innerHeight;
const onMove = (e2) => {
e2.preventDefault();
const dx = e2.clientX - startX;
const dy = e2.clientY - startY;
this.innerWidth = Math.max(this.innerMinWidth, Math.min(2000, startW + dx));
this.innerHeight = Math.max(this.innerMinHeight, Math.min(1200, startH + dy));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
/** 内框左上角拖拽:仅改变 innerWidth / innerHeight */
onInnerResizeTopLeft(e) {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = this.innerWidth;
const startH = this.innerHeight;
const onMove = (e2) => {
e2.preventDefault();
const dx = e2.clientX - startX;
const dy = e2.clientY - startY;
this.innerWidth = Math.max(this.innerMinWidth, Math.min(2000, startW - dx));
this.innerHeight = Math.max(this.innerMinHeight, Math.min(1200, startH - dy));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
/** 恢复内框默认大小 */
restoreInnerSize() {
this.innerWidth = 1168;
this.innerHeight = 560;
this.$message.success('已恢复内框大小');
},
buildTooltipText(item) {
return `${item.name}\n${item.startKTime}${item.endKTime}`;
},
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 = this.$refs.ganttInnerContent;
if (container && content) 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;
});
}
}
};
</script>
<style scoped>
.gantt-shell {
position: relative;
}
.gantt-dialog-body {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 16px 16px;
background: #f7f8fa;
overflow: hidden;
}
.gantt-toolbar {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 12px;
}
.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-shrink: 0;
overflow: auto;
border-radius: 6px;
box-shadow: 0 0 0 1px #ebeef5;
background: #fff;
}
.gantt-inner-content {
min-width: min-content;
min-height: min-content;
}
.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;
}
/* 横向仍可滚动,但隐藏本层滚动条,只用下边 gantt-scroll-container 的滚动条 */
.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-scroll::-webkit-scrollbar {
display: none !important;
width: 0;
height: 0;
}
.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 !important;
background: #fff;
border-bottom: none;
scrollbar-width: none;
-ms-overflow-style: none;
}
.timeline-content::-webkit-scrollbar,
.timeline-content::-webkit-scrollbar-vertical {
display: none !important;
width: 0;
height: 0;
}
.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;
}
.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);
}
.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: 12px;
padding-top: 0;
border-top: none;
font-size: 13px;
color: #606266;
}
/* 内框缩放手柄 */
.gantt-inner-resize-handle {
position: absolute;
width: 20px;
height: 20px;
z-index: 10;
background: rgba(0, 0, 0, 0.05);
pointer-events: auto;
}
.gantt-inner-resize-handle-tl {
left: 0;
top: 0;
cursor: nw-resize;
border-radius: 4px 0 0 0;
}
.gantt-inner-resize-handle-tl::after {
content: '';
position: absolute;
left: 4px;
top: 4px;
width: 8px;
height: 8px;
border-left: 2px solid #909399;
border-top: 2px solid #909399;
border-radius: 2px 0 0 0;
}
.gantt-inner-resize-handle-br {
right: 0;
bottom: 0;
cursor: se-resize;
border-radius: 0 0 4px 0;
}
.gantt-inner-resize-handle-br::after {
content: '';
position: absolute;
right: 4px;
bottom: 4px;
width: 8px;
height: 8px;
border-right: 2px solid #909399;
border-bottom: 2px solid #909399;
border-radius: 0 0 2px 0;
}
.gantt-inner-resize-handle:hover {
background: rgba(0, 0, 0, 0.1);
}
.gantt-inner-resize-handle:hover::after {
border-color: #606266;
}
.legend-title { margin-right: 16px; }
.legend-item { margin-right: 16px; display: inline-flex; align-items: center; gap: 6px; }
.legend-color {
width: 14px;
height: 14px;
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;
}
.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 .el-dialog__body {
padding: 0 16px 16px;
overflow: hidden;
}
/* 时间轴区域:禁止垂直滚动条;.timeline-scroll 也隐藏横向条,只用下边容器的滚动条 */
.gantt-dialog .gantt-timeline-wrap,
.gantt-dialog .timeline-scroll-clip,
.gantt-dialog .timeline-scroll,
.gantt-dialog .timeline-content {
overflow-y: hidden !important;
scrollbar-width: none;
-ms-overflow-style: none;
}
.gantt-dialog .gantt-timeline-wrap::-webkit-scrollbar,
.gantt-dialog .timeline-scroll-clip::-webkit-scrollbar,
.gantt-dialog .timeline-scroll::-webkit-scrollbar,
.gantt-dialog .timeline-content::-webkit-scrollbar {
display: none !important;
width: 0;
height: 0;
}
.gantt-dialog .el-dialog__header {
padding: 12px 20px;
border-bottom: 1px solid #e4e7ed;
}
.gantt-dialog-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 0;
}
.gantt-dialog-title-text {
font-size: 18px;
color: #303133;
}
.gantt-dialog-title-actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.gantt-dialog-header-icon {
width: 40px;
height: 40px;
margin: -12px 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #909399;
cursor: pointer;
transition: color 0.2s;
border: none;
background: none;
outline: none;
}
.gantt-dialog-header-icon:hover {
color: #303133;
}
.gantt-dialog .el-dialog__headerbtn {
display: none;
}
/* 外框固定:不可缩放,固定 1200*800 */
.el-dialog.gantt-dialog.gantt-dialog-fixed {
width: 1200px !important;
height: 800px !important;
max-width: none !important;
resize: none !important;
overflow: hidden !important;
position: relative;
}
.gantt-dialog .gantt-dialog-body {
position: relative;
}
/* el-table:先清掉所有 border,再只给格子加线(甘特图内若使用 el-table 时生效) */
.gantt-dialog .el-table,
.gantt-dialog .el-table th,
.gantt-dialog .el-table td {
border: none !important;
}
.gantt-dialog .el-table::before {
display: none !important;
}
.gantt-dialog .el-table__fixed::before,
.gantt-dialog .el-table__fixed-right::before {
display: none !important;
}
.gantt-dialog .el-table__body-wrapper,
.gantt-dialog .el-table__header-wrapper {
border: none !important;
}
/* 手动给格子加线:只保留单元格之间的分隔线 */
.gantt-dialog .el-table td {
border-bottom: 1px solid #ebeef5 !important;
}
.gantt-dialog .el-table th {
border-bottom: 1px solid #ebeef5 !important;
}
.gantt-dialog .el-table td + td,
.gantt-dialog .el-table th + th {
border-left: 1px solid #ebeef5 !important;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>