2026-05-15 16:36:50 +08:00
|
|
|
<template>
|
|
|
|
|
<section class="card trend-chart-panel" v-loading="loading">
|
2026-05-21 14:07:10 +08:00
|
|
|
<div class="panel-header">
|
|
|
|
|
<span v-if="trendResult" class="panel-meta">{{ trendResult.displayPointCount || 0 }} 点</span>
|
|
|
|
|
<span v-else class="panel-meta">趋势图</span>
|
|
|
|
|
|
|
|
|
|
<div class="trend-tool-groups">
|
|
|
|
|
<div v-for="group in trendToolGroups" :key="group.key" class="trend-tool-group">
|
|
|
|
|
<el-tooltip
|
|
|
|
|
v-for="item in group.items"
|
|
|
|
|
:key="item.action"
|
|
|
|
|
:content="getTrendToolTooltip(item)"
|
|
|
|
|
placement="top"
|
|
|
|
|
>
|
|
|
|
|
<el-button
|
|
|
|
|
:type="isTrendToolActive(item.action) ? 'primary' : 'default'"
|
|
|
|
|
:icon="item.icon"
|
|
|
|
|
:disabled="isTrendToolDisabled(item.action)"
|
|
|
|
|
circle
|
|
|
|
|
@click="handleTrendToolAction(item.action)"
|
|
|
|
|
/>
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-15 16:36:50 +08:00
|
|
|
</div>
|
|
|
|
|
|
2026-05-21 14:07:10 +08:00
|
|
|
<div v-if="hasSeries" ref="chartExportTargetRef" class="chart-list steady-trend-export-target">
|
|
|
|
|
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
|
|
|
|
<div class="chart-body">
|
|
|
|
|
<LineChart :options="group.options" :group="group.group" @chart-data-zoom="handleChartDataZoom" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-15 16:36:50 +08:00
|
|
|
</div>
|
|
|
|
|
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
2026-05-21 14:07:10 +08:00
|
|
|
|
|
|
|
|
<el-dialog v-model="fullscreenVisible" title="趋势图全屏展示" fullscreen append-to-body destroy-on-close>
|
|
|
|
|
<div v-if="hasSeries" class="fullscreen-chart-body">
|
|
|
|
|
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
|
|
|
|
<div class="chart-body">
|
|
|
|
|
<LineChart
|
|
|
|
|
:options="group.options"
|
|
|
|
|
:group="group.group"
|
|
|
|
|
@chart-data-zoom="handleChartDataZoom"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
|
|
|
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
|
2026-05-15 16:36:50 +08:00
|
|
|
</section>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-21 14:07:10 +08:00
|
|
|
import {
|
|
|
|
|
ArrowDownBold,
|
|
|
|
|
ArrowLeftBold,
|
|
|
|
|
ArrowRightBold,
|
|
|
|
|
ArrowUpBold,
|
|
|
|
|
Crop,
|
|
|
|
|
DataAnalysis,
|
|
|
|
|
FullScreen,
|
|
|
|
|
Mouse,
|
|
|
|
|
Picture,
|
|
|
|
|
Pointer,
|
|
|
|
|
RefreshLeft
|
|
|
|
|
} from '@element-plus/icons-vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import html2canvas from 'html2canvas'
|
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
2026-05-15 16:36:50 +08:00
|
|
|
import LineChart from '@/components/echarts/line/index.vue'
|
|
|
|
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
2026-05-21 14:07:10 +08:00
|
|
|
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
|
|
|
|
|
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
|
|
|
|
|
import type { Component } from 'vue'
|
2026-05-15 16:36:50 +08:00
|
|
|
|
|
|
|
|
defineOptions({
|
|
|
|
|
name: 'SteadyTrendChartPanel'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
trendResult: SteadyDataView.SteadyTrendQueryResult | null
|
|
|
|
|
loading: boolean
|
|
|
|
|
}>()
|
|
|
|
|
|
2026-05-21 14:07:10 +08:00
|
|
|
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
|
|
|
|
|
type SteadyTrendToolAction =
|
|
|
|
|
| 'x-zoom-in'
|
|
|
|
|
| 'x-zoom-out'
|
|
|
|
|
| 'y-zoom-in'
|
|
|
|
|
| 'y-zoom-out'
|
|
|
|
|
| 'box-zoom'
|
|
|
|
|
| 'wheel-zoom'
|
|
|
|
|
| 'reset'
|
|
|
|
|
| 'pan'
|
|
|
|
|
| 'fullscreen'
|
|
|
|
|
| 'download-image'
|
|
|
|
|
| 'query-data'
|
|
|
|
|
|
|
|
|
|
const trendToolGroups: Array<{
|
|
|
|
|
key: string
|
|
|
|
|
items: Array<{
|
|
|
|
|
action: SteadyTrendToolAction
|
|
|
|
|
label: string
|
|
|
|
|
icon: Component
|
|
|
|
|
}>
|
|
|
|
|
}> = [
|
|
|
|
|
{
|
|
|
|
|
key: 'viewport',
|
|
|
|
|
items: [
|
|
|
|
|
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse },
|
|
|
|
|
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
|
|
|
|
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
|
|
|
|
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
|
|
|
|
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
|
|
|
|
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
|
|
|
|
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
|
|
|
|
{ action: 'pan', label: '平移', icon: Pointer }
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'view',
|
|
|
|
|
items: [
|
|
|
|
|
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
|
|
|
|
{ action: 'download-image', label: '下载图片', icon: Picture },
|
|
|
|
|
{ action: 'query-data', label: '数据查询', icon: DataAnalysis }
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
|
|
|
|
|
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 10 }
|
|
|
|
|
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
|
|
|
|
const trendYZoomScale = ref(1)
|
|
|
|
|
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
|
|
|
|
|
const wheelZoomEnabled = ref(false)
|
|
|
|
|
const fullscreenVisible = ref(false)
|
|
|
|
|
const dataTableVisible = ref(false)
|
|
|
|
|
const chartExportTargetRef = ref<HTMLElement>()
|
2026-05-15 16:36:50 +08:00
|
|
|
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
|
2026-05-21 14:07:10 +08:00
|
|
|
const chartGroups = computed(() =>
|
|
|
|
|
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
|
|
|
|
|
activeTool: activeTrendInteractionMode.value,
|
|
|
|
|
wheelZoomEnabled: wheelZoomEnabled.value,
|
|
|
|
|
yZoomScale: trendYZoomScale.value
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
const canPanTrendChart = computed(() => {
|
|
|
|
|
const { start, end } = trendXZoomRange.value
|
|
|
|
|
|
|
|
|
|
return hasSeries.value && (start > 0 || end < 100)
|
|
|
|
|
})
|
|
|
|
|
const isDefaultTrendXZoomRange = computed(() => {
|
|
|
|
|
const { start, end } = trendXZoomRange.value
|
|
|
|
|
|
|
|
|
|
return start === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.start && end === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.end
|
|
|
|
|
})
|
|
|
|
|
const canResetTrendChart = computed(() => {
|
|
|
|
|
const changedYZoom = trendYZoomScale.value !== 1
|
|
|
|
|
const changedInteractionMode = activeTrendInteractionMode.value !== 'none'
|
|
|
|
|
const changedWheelZoom = wheelZoomEnabled.value
|
|
|
|
|
|
|
|
|
|
return hasSeries.value && (!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const resetTrendToolState = () => {
|
|
|
|
|
trendXZoomRange.value = { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
|
|
|
|
|
trendYZoomScale.value = 1
|
|
|
|
|
activeTrendInteractionMode.value = 'none'
|
|
|
|
|
wheelZoomEnabled.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const zoomTrendXAxis = (ratio: number) => {
|
|
|
|
|
const { start, end } = trendXZoomRange.value
|
|
|
|
|
const center = (start + end) / 2
|
|
|
|
|
const nextWidth = Math.min(Math.max((end - start) * ratio, 1), 100)
|
|
|
|
|
const nextStart = clampPercent(center - nextWidth / 2)
|
|
|
|
|
const nextEnd = clampPercent(center + nextWidth / 2)
|
|
|
|
|
|
|
|
|
|
if (nextStart === 0) {
|
|
|
|
|
trendXZoomRange.value = { start: 0, end: nextWidth }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (nextEnd === 100) {
|
|
|
|
|
trendXZoomRange.value = { start: 100 - nextWidth, end: 100 }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
trendXZoomRange.value = { start: nextStart, end: nextEnd }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isTrendToolActive = (action: SteadyTrendToolAction) => {
|
|
|
|
|
if (action === 'fullscreen') return fullscreenVisible.value
|
|
|
|
|
if (action === 'wheel-zoom') return wheelZoomEnabled.value
|
|
|
|
|
if (action === 'box-zoom' || action === 'pan') return activeTrendInteractionMode.value === action
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isTrendToolDisabled = (action: SteadyTrendToolAction) => {
|
|
|
|
|
if (action === 'query-data') return false
|
|
|
|
|
if (!hasSeries.value) return true
|
|
|
|
|
if (action === 'pan') return !canPanTrendChart.value
|
|
|
|
|
if (action === 'reset') return !canResetTrendChart.value
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getTrendToolTooltip = (item: { action: SteadyTrendToolAction; label: string }) => {
|
|
|
|
|
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
|
|
|
|
|
return '请先放大 X 轴或框选局部区域后再平移'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return item.label
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const downloadSteadyTrendImage = async () => {
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
|
|
|
|
const targetElement = chartExportTargetRef.value
|
|
|
|
|
|
|
|
|
|
if (!targetElement) {
|
|
|
|
|
ElMessage.warning('暂无可下载的趋势图')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const canvas = await html2canvas(targetElement, {
|
|
|
|
|
backgroundColor: '#ffffff',
|
|
|
|
|
scale: window.devicePixelRatio || 1,
|
|
|
|
|
useCORS: true
|
|
|
|
|
})
|
|
|
|
|
const imageUrl = canvas.toDataURL('image/png')
|
|
|
|
|
const exportFile = document.createElement('a')
|
|
|
|
|
|
|
|
|
|
exportFile.style.display = 'none'
|
|
|
|
|
exportFile.download = `steady-trend-${Date.now()}.png`
|
|
|
|
|
exportFile.href = imageUrl
|
|
|
|
|
document.body.appendChild(exportFile)
|
|
|
|
|
exportFile.click()
|
|
|
|
|
document.body.removeChild(exportFile)
|
|
|
|
|
|
|
|
|
|
ElMessage.success('趋势图图片下载成功')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleTrendToolAction = async (action: SteadyTrendToolAction) => {
|
|
|
|
|
if (isTrendToolDisabled(action)) return
|
|
|
|
|
|
|
|
|
|
switch (action) {
|
|
|
|
|
case 'x-zoom-in':
|
|
|
|
|
zoomTrendXAxis(0.8)
|
|
|
|
|
break
|
|
|
|
|
case 'x-zoom-out':
|
|
|
|
|
zoomTrendXAxis(1.25)
|
|
|
|
|
break
|
|
|
|
|
case 'y-zoom-in':
|
|
|
|
|
trendYZoomScale.value = Math.max(trendYZoomScale.value * 0.8, 0.1)
|
|
|
|
|
break
|
|
|
|
|
case 'y-zoom-out':
|
|
|
|
|
trendYZoomScale.value = Math.min(trendYZoomScale.value * 1.25, 10)
|
|
|
|
|
break
|
|
|
|
|
case 'box-zoom':
|
|
|
|
|
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
|
|
|
|
|
break
|
|
|
|
|
case 'wheel-zoom':
|
|
|
|
|
wheelZoomEnabled.value = !wheelZoomEnabled.value
|
|
|
|
|
break
|
|
|
|
|
case 'pan':
|
|
|
|
|
if (!canPanTrendChart.value) {
|
|
|
|
|
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
|
|
|
|
|
activeTrendInteractionMode.value = 'none'
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'pan' ? 'none' : 'pan'
|
|
|
|
|
break
|
|
|
|
|
case 'reset':
|
|
|
|
|
resetTrendToolState()
|
|
|
|
|
break
|
|
|
|
|
case 'fullscreen':
|
|
|
|
|
fullscreenVisible.value = true
|
|
|
|
|
break
|
|
|
|
|
case 'download-image':
|
|
|
|
|
await downloadSteadyTrendImage()
|
|
|
|
|
break
|
|
|
|
|
case 'query-data':
|
|
|
|
|
if (!hasSeries.value) {
|
|
|
|
|
ElMessage.warning('请先查询趋势数据')
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
dataTableVisible.value = true
|
|
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
|
|
|
|
trendXZoomRange.value = {
|
|
|
|
|
start: clampPercent(value.start),
|
|
|
|
|
end: clampPercent(value.end)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
|
|
|
|
|
activeTrendInteractionMode.value = 'none'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.trendResult,
|
|
|
|
|
() => {
|
|
|
|
|
// 新查询结果应回到完整时间范围,避免沿用上一批数据的局部缩放窗口。
|
|
|
|
|
resetTrendToolState()
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-05-15 16:36:50 +08:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
.trend-chart-panel {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
min-height: 0;
|
2026-05-20 08:32:24 +08:00
|
|
|
overflow: hidden;
|
2026-05-15 16:36:50 +08:00
|
|
|
padding: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex: none;
|
|
|
|
|
align-items: center;
|
2026-05-21 14:07:10 +08:00
|
|
|
justify-content: flex-start;
|
|
|
|
|
gap: 12px;
|
2026-05-15 16:36:50 +08:00
|
|
|
margin-bottom: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel-meta {
|
|
|
|
|
color: var(--el-text-color-secondary);
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 14:07:10 +08:00
|
|
|
.trend-tool-groups {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.trend-tool-group {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.trend-tool-group + .trend-tool-group {
|
|
|
|
|
padding-left: 8px;
|
|
|
|
|
border-left: 1px dashed var(--el-border-color);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.trend-tool-group :deep(.el-button.is-circle) {
|
|
|
|
|
width: 28px;
|
|
|
|
|
height: 28px;
|
|
|
|
|
padding: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-list {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex: 1;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.fullscreen-chart-body {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
height: calc(100vh - 96px);
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-group {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex: 1 0 240px;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
min-height: 220px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
background: var(--cn-color-canvas-bg);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 16:36:50 +08:00
|
|
|
.chart-body {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-height: 0;
|
2026-05-21 14:07:10 +08:00
|
|
|
padding: 0 8px 8px;
|
2026-05-15 16:36:50 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-empty {
|
|
|
|
|
flex: 1;
|
|
|
|
|
}
|
|
|
|
|
</style>
|