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
1048 lines
31 KiB
|
3 weeks ago
|
<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>
|