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.
1136 lines
36 KiB
1136 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>
|
|
|