Эх сурвалжийг харах

[Feat 0000]新版Echart样式组件

bobo04052021@163.com 5 өдөр өмнө
parent
commit
046a5586eb

+ 265 - 0
src/views/vent/testChart/BundleMonitorChart.vue

@@ -0,0 +1,265 @@
+<template>
+  <div class="bundle-monitor-chart-wrap">
+    <div class="chart-header">
+      <div class="chart-title">{{ title }}</div>
+    </div>
+    <div ref="chartDomRef" class="chart-container"></div>
+  </div>
+</template>
+
+<script setup>
+  import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
+  import * as echarts from 'echarts';
+
+  const props = defineProps({
+    xAxisData: {
+      type: Array,
+      default: () => [],
+    },
+    // [{ name, unit, data, color, yAxisIndex }]
+    seriesData: {
+      type: Array,
+      default: () => [],
+    },
+    height: {
+      type: String,
+      default: '440px',
+    },
+    title: {
+      type: String,
+      default: '束管监测',
+    },
+  });
+
+  const emit = defineEmits(['chart-click']);
+
+  const chartDomRef = ref(null);
+  let chartInstance = null;
+
+  const seriesColors = ['#FDB146', '#00FFA8', '#AE19FF', '#DA3914', '#03C2EC', '#8ccf9b'];
+
+  function getSeriesColor(serie, index) {
+    return serie.color || seriesColors[index % seriesColors.length];
+  }
+
+  function getAxisKey(serie) {
+    return serie.yAxisIndex ?? 0;
+  }
+
+  function getNiceAxisRange(series) {
+    const values = series.flatMap((serie) => serie.data || []).filter((value) => typeof value === 'number' && Number.isFinite(value));
+    if (!values.length) return {};
+
+    const dataMin = Math.min(...values);
+    const dataMax = Math.max(...values);
+    const min = Math.min(0, dataMin);
+    const max = dataMax + Math.max((dataMax - min) * 0.1, Math.abs(dataMax) * 0.1, 0.1);
+    const roughStep = (max - min) / 5 || 1;
+    const magnitude = 10 ** Math.floor(Math.log10(roughStep));
+    const normalizedStep = roughStep / magnitude;
+    const step = (normalizedStep <= 1 ? 1 : normalizedStep <= 2 ? 2 : normalizedStep <= 5 ? 5 : 10) * magnitude;
+    const axisMin = Math.floor(min / step) * step;
+    const axisMax = Math.ceil(max / step) * step;
+
+    return {
+      min: Number(axisMin.toPrecision(10)),
+      max: Number(axisMax.toPrecision(10)),
+      interval: Number(step.toPrecision(10)),
+    };
+  }
+
+  function createChartOption() {
+    const axisKeys = [...new Set(props.seriesData.map(getAxisKey))];
+    const axisGroups = axisKeys.map((key) => props.seriesData.filter((serie) => getAxisKey(serie) === key));
+    const extraAxisCount = Math.max(axisKeys.length - 2, 0);
+    const yAxis = axisGroups.map((series, index) => {
+      const units = [...new Set(series.map((serie) => serie.unit).filter(Boolean))];
+      return {
+        type: 'value',
+        name: series.find((serie) => serie.axisName)?.axisName || units.join('/'),
+        position: index === 0 ? 'left' : 'right',
+        offset: Math.max(index - 1, 0) * 56,
+        ...getNiceAxisRange(series),
+        nameTextStyle: { color: '#ffffff' },
+        axisLine: { show: true, lineStyle: { color: '#2ef3ef' } },
+        axisLabel: { color: '#ffffff' },
+        axisTick: { show: false },
+        splitLine: {
+          show: index === 0,
+          lineStyle: { color: 'rgba(46,243,239,0.15)', type: 'dashed' },
+        },
+      };
+    });
+
+    const series = props.seriesData.map((serie, index) => {
+      const color = getSeriesColor(serie, index);
+      return {
+        name: serie.name,
+        type: 'line',
+        smooth: true,
+        yAxisIndex: Math.max(axisKeys.indexOf(getAxisKey(serie)), 0),
+        data: serie.data || [],
+        symbol: 'none',
+        connectNulls: false,
+        lineStyle: { width: 3, color },
+        itemStyle: { color },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: color + '40' },
+            { offset: 1, color: color + '00' },
+          ]),
+        },
+      };
+    });
+
+    const seriesByName = new Map(props.seriesData.map((serie) => [serie.name, serie]));
+    return {
+      backgroundColor: 'transparent',
+      tooltip: {
+        trigger: 'axis',
+        backgroundColor: 'rgba(0,20,40,0.85)',
+        borderColor: '#2ef3ef',
+        textStyle: { color: '#ffffff' },
+        formatter: (params) => {
+          if (!params?.length) return '';
+          const rows = params.map((item) => {
+            const serie = props.seriesData[item.seriesIndex];
+            const value = typeof item.data === 'number' ? item.data : '-';
+            return `<div style="display:flex;justify-content:space-between;gap:24px"><span>${item.marker}${item.seriesName}</span><span>${value}${serie?.unit || ''}</span></div>`;
+          });
+          return `<div style="font-weight:bold;color:#35d9e5;margin-bottom:6px">${props.xAxisData[params[0].dataIndex] ?? ''}</div>${rows.join('')}`;
+        },
+      },
+      legend: {
+        type: 'scroll',
+        top: 4,
+        left: 'center',
+        width: '92%',
+        data: props.seriesData.map((serie) => serie.name),
+        formatter: (name) => {
+          const serie = seriesByName.get(name);
+          return serie?.unit ? `${name}(${serie.unit})` : name;
+        },
+        icon: 'circle',
+        itemWidth: 12,
+        itemHeight: 12,
+        itemGap: 18,
+        textStyle: { color: '#ffffff', fontSize: 14 },
+      },
+      grid: {
+        left: '5%',
+        right: 24 + extraAxisCount * 56,
+        top: '24%',
+        bottom: '18%',
+        containLabel: true,
+      },
+      xAxis: {
+        type: 'category',
+        boundaryGap: false,
+        data: props.xAxisData,
+        axisLine: { lineStyle: { color: '#2ef3ef' } },
+        axisLabel: { color: '#ffffff', fontSize: 14, hideOverlap: true },
+        axisTick: { show: false },
+        splitLine: { show: true, lineStyle: { color: 'rgba(46,243,239,0.15)' } },
+      },
+      yAxis,
+      dataZoom: [
+        {
+          type: 'slider',
+          show: true,
+          height: 20,
+          bottom: 8,
+          backgroundColor: '#152c47',
+          fillerColor: '#2ef3ef66',
+          handleStyle: { color: '#2ef3ef' },
+          textStyle: { color: '#ffffff' },
+          start: 0,
+          end: 100,
+          zoomLock: false,
+        },
+        { type: 'inside' },
+      ],
+      series,
+    };
+  }
+
+  function disposeChart() {
+    if (chartInstance) {
+      chartInstance.dispose();
+      chartInstance = null;
+    }
+  }
+
+  function renderOption() {
+    if (!chartInstance) return;
+    chartInstance.setOption(createChartOption(), true);
+  }
+
+  function handleResize() {
+    chartInstance?.resize();
+  }
+
+  function handleClick(params) {
+    if (params.componentType !== 'series') return;
+    const serie = props.seriesData[params.seriesIndex];
+    if (!serie) return;
+    emit('chart-click', {
+      name: serie.name,
+      unit: serie.unit || '',
+      dataIndex: params.dataIndex,
+      time: props.xAxisData[params.dataIndex],
+      value: Array.isArray(params.data) ? params.data[1] : params.data,
+    });
+  }
+
+  function initChart() {
+    if (!chartDomRef.value) return;
+    disposeChart();
+    chartInstance = echarts.init(chartDomRef.value);
+    renderOption();
+    chartInstance.on('click', handleClick);
+    window.addEventListener('resize', handleResize);
+  }
+
+  watch(
+    () => [props.xAxisData, props.seriesData],
+    () => {
+      nextTick(() => renderOption());
+    },
+    { deep: true }
+  );
+
+  onMounted(() => {
+    nextTick(() => initChart());
+  });
+
+  onBeforeUnmount(() => {
+    disposeChart();
+    window.removeEventListener('resize', handleResize);
+  });
+</script>
+
+<style scoped>
+  .bundle-monitor-chart-wrap {
+    width: 100%;
+    height: v-bind(height);
+    display: flex;
+    flex-direction: column;
+  }
+  .chart-header {
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    padding: 10px 16px;
+  }
+  .chart-title {
+    color: #ffffff;
+    font-size: 24px;
+    font-weight: bold;
+  }
+  .chart-container {
+    flex: 1;
+    width: 100%;
+    min-height: 0;
+  }
+</style>

+ 320 - 0
src/views/vent/testChart/DailyAnalysisBarChart.vue

@@ -0,0 +1,320 @@
+<template>
+  <div class="daily-analysis-chart-wrap">
+    <div class="chart-header">
+      <div class="chart-title">{{ title }}</div>
+      <div class="chart-subtitle"></div>
+    </div>
+    <div ref="chartDomRef" class="chart-container"></div>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import * as echarts from 'echarts';
+
+const props = defineProps({
+  categories: {
+    type: Array,
+    default: () => [],
+  },
+  // [{ name, data, unit, yAxisIndex, color }]
+  seriesData: {
+    type: Array,
+    default: () => [],
+  },
+  // [{ name, min, max, interval, position }]
+  yAxisConfig: {
+    type: Array,
+    default: () => [],
+  },
+  height: {
+    type: String,
+    default: '480px',
+  },
+  title: {
+    type: String,
+    default: '日报分析',
+  },
+});
+
+const emit = defineEmits(['chart-click']);
+const chartDomRef = ref(null);
+let chartInstance = null;
+
+const seriesColors = ['#5C7CFA', '#B9DE2C', '#575B7A', '#FF984F', '#0DA9DA', '#FFCA28', '#F65D91', '#8060AA', '#38B99B', '#5578E7'];
+
+function getSeriesColor(serie, index) {
+  return serie.color || seriesColors[index % seriesColors.length];
+}
+
+function getColorWithAlpha(color, alpha) {
+  const match = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(color);
+  if (!match) return color;
+  const hex =
+    match[1].length === 3
+      ? match[1]
+          .split('')
+          .map((value) => value + value)
+          .join('')
+      : match[1];
+  const channels = [0, 2, 4].map((index) => parseInt(hex.slice(index, index + 2), 16));
+  return `rgba(${channels.join(', ')}, ${alpha})`;
+}
+
+function getSeriesAxisIndex(serie) {
+  return Number.isInteger(serie.yAxisIndex) && serie.yAxisIndex >= 0 ? serie.yAxisIndex : 0;
+}
+
+function getAxisRange(series, axisConfig) {
+  if (Number.isFinite(axisConfig.min) && Number.isFinite(axisConfig.max)) {
+    return {
+      min: axisConfig.min,
+      max: axisConfig.max,
+      ...(Number.isFinite(axisConfig.interval) ? { interval: axisConfig.interval } : {}),
+    };
+  }
+
+  const values = series.flatMap((serie) => serie.data || []).filter((value) => typeof value === 'number' && Number.isFinite(value));
+  if (!values.length) return {};
+
+  const dataMax = Math.max(...values);
+  const max = dataMax + Math.max(Math.abs(dataMax) * 0.1, 0.1);
+  return { min: 0, max };
+}
+
+function createChartOption() {
+  const axisCount = Math.max(props.yAxisConfig.length, ...props.seriesData.map((serie) => getSeriesAxisIndex(serie) + 1), 1);
+  const axes = Array.from({ length: axisCount }, (_, index) => {
+    const axisConfig = props.yAxisConfig[index] || {};
+    const axisSeries = props.seriesData.filter((serie) => getSeriesAxisIndex(serie) === index);
+    const axisUnit = axisSeries.find((serie) => serie.unit)?.unit || '';
+    return {
+      type: 'value',
+      name: axisConfig.name || axisUnit,
+      position: axisConfig.position || (index === 0 ? 'left' : 'right'),
+      offset: index > 1 ? (index - 1) * 52 : 0,
+      ...getAxisRange(axisSeries, axisConfig),
+      nameTextStyle: { color: '#91a9c7', fontSize: 13, padding: [0, 0, 8, 0] },
+      axisLine: { show: true, lineStyle: { color: 'rgba(77, 188, 255, 0.7)' } },
+      axisTick: { show: false },
+      axisLabel: { color: '#d6e5f7', fontSize: 12 },
+      splitLine: {
+        show: index === 0,
+        lineStyle: { color: 'rgba(71, 153, 218, 0.18)', type: 'dashed' },
+      },
+    };
+  });
+
+  const seriesByName = new Map(props.seriesData.map((serie) => [serie.name, serie]));
+  const series = props.seriesData.map((serie, index) => {
+    const color = getSeriesColor(serie, index);
+    return {
+      name: serie.name,
+      type: 'bar',
+      yAxisIndex: getSeriesAxisIndex(serie),
+      data: serie.data || [],
+      barMaxWidth: 22,
+      barMinWidth: 3,
+      barGap: '18%',
+      barCategoryGap: '30%',
+      itemStyle: {
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color },
+          { offset: 1, color: getColorWithAlpha(color, 0.62) },
+        ]),
+        borderColor: getColorWithAlpha(color, 0.87),
+        borderWidth: 1,
+        borderRadius: [4, 4, 1, 1],
+        shadowBlur: 9,
+        shadowColor: getColorWithAlpha(color, 0.33),
+      },
+      emphasis: {
+        focus: 'series',
+        itemStyle: {
+          shadowBlur: 18,
+          shadowColor: getColorWithAlpha(color, 0.6),
+          borderColor: '#ffffff',
+        },
+      },
+    };
+  });
+
+  return {
+    backgroundColor: 'transparent',
+    animationDuration: 850,
+    animationEasing: 'cubicOut',
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'shadow',
+        shadowStyle: { color: 'rgba(48, 132, 204, 0.12)' },
+      },
+      backgroundColor: 'rgba(5, 18, 39, 0.96)',
+      borderColor: 'rgba(56, 189, 248, 0.65)',
+      borderWidth: 1,
+      padding: [10, 14],
+      textStyle: { color: '#eaf6ff', fontSize: 13 },
+      formatter: (params) => {
+        if (!params?.length) return '';
+        const rows = params.map((item) => {
+          const serie = props.seriesData[item.seriesIndex];
+          const value = typeof item.data === 'number' ? item.data : '-';
+          return `<div style="display:flex;justify-content:space-between;gap:24px;padding:2px 0"><span>${item.marker}${
+            item.seriesName
+          }</span><b style="color:#fff">${value}${serie?.unit || ''}</b></div>`;
+        });
+        return `<div style="font-weight:600;color:#6ee7ff;margin-bottom:7px">${params[0].axisValueLabel}</div>${rows.join('')}`;
+      },
+    },
+    legend: {
+      type: 'plain',
+      top: 8,
+      left: 'center',
+      width: '94%',
+      data: props.seriesData.map((serie) => serie.name),
+      icon: 'roundRect',
+      itemWidth: 18,
+      itemHeight: 12,
+      itemGap: 14,
+      textStyle: { color: '#d9e8f8', fontSize: 13 },
+      pageTextStyle: { color: '#d9e8f8' },
+      formatter: (name) => {
+        const serie = seriesByName.get(name);
+        return serie?.unit ? `${name}(${serie.unit})` : name;
+      },
+    },
+    grid: {
+      top: '25%',
+      left: 52,
+      right: 56 + Math.max(axisCount - 2, 0) * 52,
+      bottom: 76,
+      containLabel: true,
+    },
+    xAxis: {
+      type: 'category',
+      data: props.categories,
+      axisLine: { lineStyle: { color: 'rgba(99, 174, 226, 0.72)' } },
+      axisTick: { show: false },
+      axisLabel: {
+        color: '#d6e5f7',
+        fontSize: 12,
+        interval: 0,
+        width: 120,
+        overflow: 'truncate',
+        margin: 12,
+      },
+      splitLine: { show: true, lineStyle: { color: 'rgba(71, 153, 218, 0.1)' } },
+    },
+    yAxis: axes,
+    dataZoom: [
+      {
+        type: 'slider',
+        show: props.categories.length > 4,
+        height: 14,
+        bottom: 18,
+        borderColor: 'rgba(69, 173, 235, 0.25)',
+        backgroundColor: 'rgba(13, 35, 65, 0.75)',
+        fillerColor: 'rgba(42, 168, 238, 0.28)',
+        handleStyle: { color: '#42c8ff', borderColor: '#a5e8ff' },
+        moveHandleStyle: { color: '#42c8ff' },
+        textStyle: { color: '#819ab8' },
+        dataBackground: {
+          lineStyle: { color: 'rgba(75, 171, 224, 0.5)' },
+          areaStyle: { color: 'rgba(48, 129, 186, 0.14)' },
+        },
+        start: 0,
+        end: 100,
+      },
+      { type: 'inside' },
+    ],
+    series,
+  };
+}
+
+function disposeChart() {
+  if (chartInstance) {
+    chartInstance.dispose();
+    chartInstance = null;
+  }
+}
+
+function renderOption() {
+  if (!chartInstance) return;
+  chartInstance.setOption(createChartOption(), true);
+}
+
+function handleResize() {
+  chartInstance?.resize();
+}
+
+function handleClick(params) {
+  if (params.componentType !== 'series') return;
+  const serie = props.seriesData[params.seriesIndex];
+  if (!serie) return;
+  emit('chart-click', {
+    category: props.categories[params.dataIndex],
+    name: serie.name,
+    unit: serie.unit || '',
+    value: params.data,
+    dataIndex: params.dataIndex,
+    seriesIndex: params.seriesIndex,
+  });
+}
+
+function initChart() {
+  if (!chartDomRef.value) return;
+  disposeChart();
+  chartInstance = echarts.init(chartDomRef.value);
+  renderOption();
+  chartInstance.on('click', handleClick);
+  window.addEventListener('resize', handleResize);
+}
+
+watch(
+  () => [props.categories, props.seriesData, props.yAxisConfig],
+  () => nextTick(() => renderOption()),
+  { deep: true }
+);
+
+onMounted(() => nextTick(() => initChart()));
+
+onBeforeUnmount(() => {
+  disposeChart();
+  window.removeEventListener('resize', handleResize);
+});
+</script>
+
+<style scoped>
+.daily-analysis-chart-wrap {
+  width: 100%;
+  height: v-bind(height);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: radial-gradient(ellipse at 50% 0%, rgba(15, 107, 163, 0.13), transparent 56%),
+    linear-gradient(135deg, rgba(10, 25, 49, 0.6), rgba(7, 17, 36, 0.2));
+}
+.chart-header {
+  display: flex;
+  align-items: baseline;
+  gap: 14px;
+  padding: 10px 16px 0;
+}
+.chart-title {
+  color: #f0f8ff;
+  font-size: 24px;
+  font-weight: 700;
+  letter-spacing: 1px;
+  text-shadow: 0 0 12px rgba(62, 190, 255, 0.42);
+}
+.chart-subtitle {
+  color: #6d8fb3;
+  font-size: 12px;
+  letter-spacing: 1px;
+}
+.chart-container {
+  flex: 1;
+  width: 100%;
+  min-height: 0;
+}
+</style>

+ 375 - 0
src/views/vent/testChart/MonitorTrendChart.vue

@@ -0,0 +1,375 @@
+<template>
+  <div class="monitor-trend-chart">
+    <div class="chart-header">
+      <div class="chart-title">{{ title }}</div>
+      <div class="chart-tools">
+        <div class="metric-picker">
+          <span class="picker-label">监测指标</span>
+          <select :value="selectedMetricKey" class="metric-select" @change="handleMetricChange">
+            <option v-for="metric in metrics" :key="metric.key" :value="metric.key">
+              {{ metric.name }}
+            </option>
+          </select>
+        </div>
+      </div>
+    </div>
+    <div class="chart-container">
+      <span v-if="selectedMetric?.unit" class="chart-axis-unit">{{ selectedMetric.unit }}</span>
+      <div ref="chartDomRef" class="chart-canvas"></div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
+  import * as echarts from 'echarts';
+
+  const props = defineProps({
+    xAxisData: {
+      type: Array,
+      default: () => [],
+    },
+    // [{ key, name, unit, data, color, precision }]
+    metrics: {
+      type: Array,
+      default: () => [],
+    },
+    modelValue: {
+      type: String,
+      default: '',
+    },
+    height: {
+      type: String,
+      default: '440px',
+    },
+    title: {
+      type: String,
+      default: '监测曲线',
+    },
+  });
+
+  const emit = defineEmits(['update:modelValue', 'metric-change', 'chart-click']);
+  const chartDomRef = ref(null);
+  const internalMetricKey = ref('');
+  let chartInstance = null;
+
+  const selectedMetricKey = computed(() => {
+    const requestedKey = props.modelValue || internalMetricKey.value;
+    return props.metrics.find((metric) => metric.key === requestedKey)?.key || props.metrics[0]?.key || '';
+  });
+
+  const selectedMetric = computed(() => props.metrics.find((metric) => metric.key === selectedMetricKey.value));
+
+  const metricColors = ['#35d9e5', '#ffbd4a', '#a88bff', '#ff6b81', '#39d98a', '#54a6ff'];
+
+  function getMetricColor(metric, index) {
+    return metric.color || metricColors[index % metricColors.length];
+  }
+
+  function getColorWithAlpha(color, alpha) {
+    const match = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(color);
+    if (!match) return color;
+    const hex =
+      match[1].length === 3
+        ? match[1]
+            .split('')
+            .map((value) => value + value)
+            .join('')
+        : match[1];
+    const channels = [0, 2, 4].map((index) => parseInt(hex.slice(index, index + 2), 16));
+    return `rgba(${channels.join(', ')}, ${alpha})`;
+  }
+
+  function getNiceAxisRange(data) {
+    const values = data.filter((value) => typeof value === 'number' && Number.isFinite(value));
+    if (!values.length) return { min: 0, max: 1, interval: 0.2 };
+
+    const dataMin = Math.min(...values);
+    const dataMax = Math.max(...values);
+    const padding = (dataMax - dataMin) * 0.15 || Math.max(Math.abs(dataMax) * 0.05, 0.1);
+    const roughStep = (dataMax - dataMin + padding * 2) / 5 || 1;
+    const magnitude = 10 ** Math.floor(Math.log10(roughStep));
+    const normalizedStep = roughStep / magnitude;
+    const step = (normalizedStep <= 1 ? 1 : normalizedStep <= 2 ? 2 : normalizedStep <= 5 ? 5 : 10) * magnitude;
+    const min = Math.floor((dataMin - padding) / step) * step;
+    const max = Math.ceil((dataMax + padding) / step) * step;
+
+    return {
+      min: Number(min.toPrecision(10)),
+      max: Number((max > min ? max : min + step).toPrecision(10)),
+      interval: Number(step.toPrecision(10)),
+    };
+  }
+
+  function createChartOption() {
+    const metric = selectedMetric.value;
+    if (!metric) {
+      return {
+        backgroundColor: 'transparent',
+        xAxis: { type: 'category', data: [] },
+        yAxis: { type: 'value' },
+        series: [],
+      };
+    }
+
+    const metricIndex = props.metrics.findIndex((item) => item.key === metric.key);
+    const color = getMetricColor(metric, metricIndex);
+    return {
+      backgroundColor: 'transparent',
+      animationDuration: 700,
+      animationEasing: 'cubicOut',
+      tooltip: {
+        trigger: 'axis',
+        backgroundColor: 'rgba(5, 18, 39, 0.96)',
+        borderColor: getColorWithAlpha(color, 0.6),
+        borderWidth: 1,
+        padding: [10, 14],
+        textStyle: { color: '#eaf6ff', fontSize: 13 },
+        formatter: (params) => {
+          if (!params?.length) return '';
+          const item = params[0];
+          const value = typeof item.value === 'number' && Number.isFinite(item.value) ? item.value.toFixed(metric.precision ?? 2) : '--';
+          return `<div style="font-weight:600;color:${color};margin-bottom:7px">${
+            item.axisValueLabel
+          }</div><div style="display:flex;justify-content:space-between;gap:24px"><span>${item.marker}${
+            metric.name
+          }</span><b style="color:#fff">${value} ${metric.unit || ''}</b></div>`;
+        },
+      },
+      grid: {
+        left: 80,
+        right: 28,
+        top: 28,
+        bottom: 76,
+        containLabel: true,
+      },
+      xAxis: {
+        type: 'category',
+        boundaryGap: false,
+        data: props.xAxisData,
+        axisLine: { lineStyle: { color: 'rgba(99, 174, 226, 0.72)' } },
+        axisTick: { show: false },
+        axisLabel: { color: '#d6e5f7', fontSize: 12, hideOverlap: true, margin: 12 },
+        splitLine: { show: true, lineStyle: { color: 'rgba(71, 153, 218, 0.12)' } },
+      },
+      yAxis: {
+        type: 'value',
+        ...getNiceAxisRange(metric.data || []),
+        axisLine: { show: true, lineStyle: { color: `${color}99` } },
+        axisTick: { show: false },
+        axisLabel: { color: '#d6e5f7', fontSize: 12 },
+        splitLine: { lineStyle: { color: 'rgba(71, 153, 218, 0.18)', type: 'dashed' } },
+      },
+      dataZoom: [
+        {
+          type: 'slider',
+          show: props.xAxisData.length > 12,
+          height: 14,
+          bottom: 18,
+          borderColor: 'rgba(69, 173, 235, 0.25)',
+          backgroundColor: 'rgba(13, 35, 65, 0.75)',
+          fillerColor: getColorWithAlpha(color, 0.27),
+          handleStyle: { color, borderColor: '#a5e8ff' },
+          moveHandleStyle: { color },
+          textStyle: { color: '#819ab8' },
+          start: 0,
+          end: 100,
+        },
+        { type: 'inside' },
+      ],
+      series: [
+        {
+          name: metric.name,
+          type: 'line',
+          smooth: true,
+          showSymbol: false,
+          connectNulls: false,
+          data: metric.data || [],
+          lineStyle: { width: 3, color, shadowBlur: 10, shadowColor: getColorWithAlpha(color, 0.53) },
+          itemStyle: { color },
+          areaStyle: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              { offset: 0, color: getColorWithAlpha(color, 0.27) },
+              { offset: 1, color: getColorWithAlpha(color, 0) },
+            ]),
+          },
+          emphasis: { focus: 'series', lineStyle: { width: 4 } },
+        },
+      ],
+    };
+  }
+
+  function renderOption() {
+    if (!chartInstance) return;
+    chartInstance.setOption(createChartOption(), true);
+  }
+
+  function handleMetricChange(event) {
+    const key = event.target.value;
+    internalMetricKey.value = key;
+    emit('update:modelValue', key);
+    const metric = props.metrics.find((item) => item.key === key);
+    if (metric) emit('metric-change', metric);
+  }
+
+  function handleChartClick(params) {
+    if (params.componentType !== 'series' || !selectedMetric.value) return;
+    emit('chart-click', {
+      key: selectedMetric.value.key,
+      name: selectedMetric.value.name,
+      unit: selectedMetric.value.unit || '',
+      dataIndex: params.dataIndex,
+      time: props.xAxisData[params.dataIndex],
+      value: Array.isArray(params.data) ? params.data[1] : params.data,
+    });
+  }
+
+  function handleResize() {
+    chartInstance?.resize();
+  }
+
+  function initChart() {
+    if (!chartDomRef.value) return;
+    chartInstance = echarts.init(chartDomRef.value);
+    renderOption();
+    chartInstance.on('click', handleChartClick);
+    window.addEventListener('resize', handleResize);
+  }
+
+  watch(
+    () => [props.xAxisData, props.metrics, selectedMetricKey.value],
+    () => nextTick(renderOption),
+    { deep: true }
+  );
+
+  onMounted(() => nextTick(initChart));
+
+  onBeforeUnmount(() => {
+    chartInstance?.dispose();
+    chartInstance = null;
+    window.removeEventListener('resize', handleResize);
+  });
+</script>
+
+<style scoped>
+  .monitor-trend-chart {
+    width: 100%;
+    height: v-bind(height);
+    display: flex;
+    flex-direction: column;
+  }
+
+  .chart-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 20px;
+    padding: 10px 16px;
+  }
+
+  .chart-title {
+    color: #ffffff;
+    font-size: 24px;
+    font-weight: bold;
+  }
+
+  .chart-tools,
+  .metric-picker,
+  .current-value {
+    display: flex;
+    align-items: center;
+  }
+
+  .chart-tools {
+    gap: 24px;
+  }
+
+  .metric-picker {
+    gap: 10px;
+  }
+
+  .picker-label,
+  .value-label {
+    color: #fff;
+    font-size: 13px;
+  }
+
+  .metric-select {
+    min-width: 140px;
+    height: 36px;
+    padding: 0 34px 0 12px;
+    color: #fff;
+    color-scheme: light;
+    border: 1px solid rgba(53, 217, 229, 0.56);
+    border-radius: 4px;
+    outline: none;
+    background: rgba(22, 63, 91, 0.92);
+    box-shadow: inset 0 0 12px rgba(53, 217, 229, 0.08);
+    cursor: pointer;
+  }
+
+  .metric-select option {
+    color: #000000;
+    background: #ffffff;
+  }
+
+  .metric-select:focus {
+    border-color: #35d9e5;
+    box-shadow: 0 0 12px rgba(53, 217, 229, 0.24);
+  }
+
+  .current-value {
+    gap: 8px;
+    min-width: 120px;
+  }
+
+  .current-value strong {
+    font-size: 20px;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .value-unit {
+    color: #91a9c7;
+    font-size: 12px;
+  }
+
+  .chart-container {
+    position: relative;
+    flex: 1;
+    width: 100%;
+    min-height: 0;
+  }
+
+  .chart-axis-unit {
+    position: absolute;
+    z-index: 1;
+    top: 4px;
+    left: 18px;
+    color: #91a9c7;
+    font-size: 12px;
+    line-height: 18px;
+    pointer-events: none;
+  }
+
+  .chart-canvas {
+    width: 100%;
+    height: 100%;
+  }
+
+  @media (max-width: 768px) {
+    .chart-header {
+      align-items: flex-start;
+      flex-direction: column;
+    }
+
+    .chart-tools {
+      width: 100%;
+      justify-content: space-between;
+      gap: 12px;
+    }
+
+    .metric-select {
+      min-width: 120px;
+    }
+  }
+</style>

+ 317 - 11
src/views/vent/testChart/index.vue

@@ -1,18 +1,53 @@
 <template>
-  <a-card style="background: #0b142b; border: 1px solid #2ef3ef">
-    <FiberTempChart
-      :x-axis-data="xAxisList"
-      :series-data="tempValueList"
-      :max-value="60"
-      :min-value="20"
-      title="光纤测温"
-      height="440px"
-      @chart-click="onChartClick"
-    />
-  </a-card>
+  <div class="test-chart-page">
+    <a-card style="background: #0b142b; border: 1px solid #2ef3ef">
+      <FiberTempChart
+        :x-axis-data="xAxisList"
+        :series-data="tempValueList"
+        :max-value="60"
+        :min-value="20"
+        title="光纤测温"
+        height="440px"
+        @chart-click="onChartClick"
+      />
+    </a-card>
+    <a-card style="background: #0b142b; border: 1px solid #2ef3ef">
+      <BundleMonitorChart
+        :x-axis-data="bundleXAxisList"
+        :series-data="bundleSeriesList"
+        title="束管监测设备曲线"
+        height="440px"
+        @chart-click="onBundleChartClick"
+      />
+    </a-card>
+    <a-card style="background: #0b142b; border: 1px solid #2ef3ef">
+      <MonitorTrendChart
+        v-model="selectedMonitorMetric"
+        :x-axis-data="switchableMonitorXAxis"
+        :metrics="switchableMonitorMetrics"
+        title="可切换监测曲线"
+        height="440px"
+        @chart-click="onSwitchableMonitorClick"
+      />
+    </a-card>
+    <a-card style="background: #0b142b; border: 1px solid #2ef3ef">
+      <DailyAnalysisBarChart
+        :categories="dailyAnalysisCategories"
+        :series-data="dailyAnalysisSeries"
+        :y-axis-config="dailyAnalysisAxes"
+        title="束管/色谱仪日报分析"
+        height="520px"
+        @chart-click="onDailyAnalysisClick"
+      />
+    </a-card>
+  </div>
 </template>
 <script setup>
+import { ref } from 'vue';
 import FiberTempChart from './FiberTempChart.vue';
+import BundleMonitorChart from './BundleMonitorChart.vue';
+import MonitorTrendChart from './MonitorTrendChart.vue';
+import DailyAnalysisBarChart from './DailyAnalysisBarChart.vue';
 const pointData = [
   { name: '测点01', temp: 24.2 },
   { name: '测点02', temp: 28.5 },
@@ -43,4 +78,275 @@ const tempValueList = pointData.map((item) => item.temp);
 function onChartClick(params) {
   console.log('点击测点', params);
 }
+
+// 束管模拟数据
+const bundleXAxisList = Array.from({ length: 60 }, (_, index) => {
+  const time = new Date(Date.UTC(2026, 9, 25, 4 + index));
+  return `${String(time.getUTCMonth() + 1).padStart(2, '0')}-${String(time.getUTCDate()).padStart(2, '0')} ${String(time.getUTCHours()).padStart(
+    2,
+    '0'
+  )}:00`;
+});
+
+const bundleSeriesSource = [
+  {
+    name: '一氧化碳',
+    field: 'coval',
+    unit: 'ppm',
+    yAxisIndex: 0,
+    color: '#FDB146',
+    values: [
+      12.67, 12.68, 12.66, 12.68, 12.65, 12.67, 12.71, 12.67, 12.71, 12.68, 12.66, 12.67, 12.68, 12.68, 12.68, 12.67, 12.66, 12.67, 12.67, 12.67,
+      12.69, 12.65, 12.71, 12.67, 12.69, 12.7, 12.66, 12.71, 12.66, 12.72, 12.67, 12.69, 12.7, 12.73, 12.69, 12.7, 12.76, 12.66, 12.64, 12.72, 12.58,
+      12.71, 12.7, 12.66, 12.65, 12.66, 12.68, 12.64, 12.66, 12.67, 12.64, 12.68, 12.73, 12.67, 12.67, 12.67, 12.72, 12.73, 12.68, 12.66,
+    ],
+  },
+  {
+    name: '乙炔',
+    field: 'chval',
+    unit: 'ppm',
+    yAxisIndex: 0,
+    color: '#00FFA8',
+    values: [
+      0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+      0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+    ],
+  },
+  {
+    name: '乙烯',
+    field: 'ch2val',
+    unit: 'ppm',
+    yAxisIndex: 0,
+    color: '#AE19FF',
+    values: [
+      0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+      0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+    ],
+  },
+  {
+    name: '二氧化碳',
+    field: 'co2val',
+    unit: '%',
+    yAxisIndex: 1,
+    color: '#9C83D9',
+    values: [
+      0.23, 0.23, 0.22, 0.22, 0.23, 0.23, 0.23, 0.23, 0.22, 0.23, 0.22, 0.23, 0.21, 0.22, 0.21, 0.23, 0.21, 0.2, 0.22, 0.21, 0.21, 0.21, 0.2, 0.21,
+      0.21, 0.23, 0.23, 0.23, 0.21, 0.23, 0.22, 0.21, 0.22, 0.23, 0.21, 0.21, 0.22, 0.21, 0.23, 0.23, 0.24, 0.24, 0.23, 0.21, 0.23, 0.22, 0.21, 0.23,
+      0.24, 0.2, 0.22, 0.2, 0.23, 0.21, 0.24, 0.2, 0.2, 0.23, 0.23, 0.21,
+    ],
+  },
+  {
+    name: '甲烷',
+    field: 'gasval',
+    unit: '%',
+    yAxisIndex: 1,
+    color: '#DA3914',
+    values: [
+      0.39, 0.38, 0.38, 0.4, 0.39, 0.4, 0.39, 0.39, 0.4, 0.4, 0.4, 0.39, 0.38, 0.39, 0.39, 0.39, 0.39, 0.4, 0.39, 0.39, 0.39, 0.39, 0.38, 0.38, 0.39,
+      0.39, 0.39, 0.39, 0.4, 0.39, 0.39, 0.39, 0.39, 0.39, 0.4, 0.39, 0.4, 0.39, 0.38, 0.39, 0.39, 0.38, 0.39, 0.39, 0.4, 0.4, 0.39, 0.38, 0.39, 0.38,
+      0.39, 0.4, 0.39, 0.4, 0.4, 0.4, 0.38, 0.39, 0.38, 0.39,
+    ],
+  },
+  {
+    name: '氧气',
+    field: 'o2val',
+    unit: '%',
+    yAxisIndex: 2,
+    color: '#03C2EC',
+    values: [
+      19.67, 19.6, 19.7, 19.68, 19.72, 19.67, 19.69, 19.68, 19.66, 19.65, 19.68, 19.7, 19.67, 19.68, 19.64, 19.74, 19.66, 19.63, 19.68, 19.7, 19.67,
+      19.67, 19.68, 19.66, 19.65, 19.6, 19.68, 19.67, 19.68, 19.67, 19.61, 19.68, 19.68, 19.68, 19.75, 19.68, 19.65, 19.63, 19.63, 19.72, 19.67,
+      19.66, 19.7, 19.65, 19.68, 19.65, 19.67, 19.63, 19.68, 19.61, 19.65, 19.7, 19.67, 19.63, 19.65, 19.68, 19.67, 19.66, 19.68, 19.71,
+    ],
+  },
+];
+
+const bundleSeriesList = bundleSeriesSource.map(({ name, unit, yAxisIndex, color, values }) => ({
+  name,
+  unit,
+  yAxisIndex,
+  color,
+  data: values,
+}));
+
+function onBundleChartClick(params) {
+  console.log('点击束管曲线', params);
+}
+
+const switchableMonitorXAxis = Array.from({ length: 24 }, (_, index) => `${String(index).padStart(2, '0')}:00`);
+const switchableMonitorMetrics = [
+  {
+    key: 'temperature',
+    name: '温度',
+    unit: '℃',
+    precision: 1,
+    color: '#FDB146',
+    valueAt: (index) => 28 + Math.sin(index * 0.48) * 2.2 + Math.cos(index * 0.19) * 0.8,
+  },
+  {
+    key: 'helium',
+    name: '氦气',
+    unit: '%',
+    precision: 3,
+    color: '#A88BFF',
+    valueAt: (index) => 0.035 + Math.sin(index * 0.36) * 0.008 + Math.cos(index * 0.17) * 0.004,
+  },
+  {
+    key: 'carbonDioxide',
+    name: '二氧化碳',
+    unit: '%',
+    precision: 3,
+    color: '#9C83D9',
+    valueAt: (index) => 0.23 + Math.sin(index * 0.31) * 0.025 + Math.cos(index * 0.14) * 0.012,
+  },
+  {
+    key: 'carbonMonoxide',
+    name: '一氧化碳',
+    unit: 'ppm',
+    precision: 2,
+    color: '#35D9E5',
+    valueAt: (index) => 12.5 + Math.sin(index * 0.42) * 1.8 + Math.cos(index * 0.13) * 0.7,
+  },
+  {
+    key: 'methane',
+    name: '甲烷',
+    unit: '%',
+    precision: 3,
+    color: '#DA3914',
+    valueAt: (index) => 0.39 + Math.sin(index * 0.29) * 0.035 + Math.cos(index * 0.11) * 0.012,
+  },
+  {
+    key: 'oxygen',
+    name: '氧气',
+    unit: '%',
+    precision: 2,
+    color: '#39D98A',
+    valueAt: (index) => 19.65 + Math.sin(index * 0.34) * 0.12 + Math.cos(index * 0.18) * 0.05,
+  },
+].map(({ valueAt, ...metric }) => ({
+  ...metric,
+  data: switchableMonitorXAxis.map((_, index) => Number(valueAt(index).toFixed(metric.precision))),
+}));
+
+const selectedMonitorMetric = ref('temperature');
+
+function onSwitchableMonitorClick(params) {
+  console.log('点击可切换监测曲线', params);
+}
+
+const dailyAnalysisCategories = ['12404工作面', '12404回风1巷', '12404回风4巷', '12404回风2巷', '22105回风顺槽'];
+
+const dailyAnalysisRecords = [
+  {
+    c2h2Max: 0.18,
+    c2h2Avg: 0.06,
+    c2h4Max: 0.42,
+    c2h4Avg: 0.16,
+    ch4Max: 0.42,
+    ch4Avg: 0.38,
+    co2Max: 0.26,
+    co2Avg: 0.23,
+    coMax: 12.75,
+    coAvg: 12.67,
+    o2Min: 19.58,
+    o2Avg: 19.67,
+  },
+  {
+    c2h2Max: 0.32,
+    c2h2Avg: 0.12,
+    c2h4Max: 0.76,
+    c2h4Avg: 0.31,
+    ch4Max: 0.46,
+    ch4Avg: 0.4,
+    co2Max: 0.29,
+    co2Avg: 0.24,
+    coMax: 10.62,
+    coAvg: 10.48,
+    o2Min: 19.42,
+    o2Avg: 19.61,
+  },
+  {
+    c2h2Max: 0.24,
+    c2h2Avg: 0.08,
+    c2h4Max: 0.58,
+    c2h4Avg: 0.2,
+    ch4Max: 0.4,
+    ch4Avg: 0.36,
+    co2Max: 0.25,
+    co2Avg: 0.21,
+    coMax: 19.72,
+    coAvg: 19.55,
+    o2Min: 19.56,
+    o2Avg: 19.68,
+  },
+  {
+    c2h2Max: 0.14,
+    c2h2Avg: 0.05,
+    c2h4Max: 0.33,
+    c2h4Avg: 0.11,
+    ch4Max: 0.38,
+    ch4Avg: 0.34,
+    co2Max: 0.23,
+    co2Avg: 0.2,
+    coMax: 14.86,
+    coAvg: 14.61,
+    o2Min: 19.47,
+    o2Avg: 19.63,
+  },
+  {
+    c2h2Max: 0.1,
+    c2h2Avg: 0.04,
+    c2h4Max: 0.28,
+    c2h4Avg: 0.09,
+    ch4Max: 0.36,
+    ch4Avg: 0.32,
+    co2Max: 0.31,
+    co2Avg: 0.25,
+    coMax: 3.84,
+    coAvg: 3.62,
+    o2Min: 19.65,
+    o2Avg: 19.71,
+  },
+];
+
+const dailyAnalysisSeriesConfig = [
+  { name: 'C₂H₂最大值', key: 'c2h2Max', unit: 'ppm', yAxisIndex: 1, color: '#5C7CFA' },
+  { name: 'C₂H₂平均值', key: 'c2h2Avg', unit: 'ppm', yAxisIndex: 1, color: '#B9DE2C' },
+  { name: 'C₂H₄最大值', key: 'c2h4Max', unit: 'ppm', yAxisIndex: 1, color: '#8583B4' },
+  { name: 'C₂H₄平均值', key: 'c2h4Avg', unit: 'ppm', yAxisIndex: 1, color: '#FF984F' },
+  { name: 'CH₄最大值', key: 'ch4Max', unit: '%', yAxisIndex: 0, color: '#0DA9DA' },
+  { name: 'CH₄平均值', key: 'ch4Avg', unit: '%', yAxisIndex: 0, color: '#FFCA28' },
+  { name: 'CO₂最大值', key: 'co2Max', unit: '%', yAxisIndex: 0, color: '#F65D91' },
+  { name: 'CO₂平均值', key: 'co2Avg', unit: '%', yAxisIndex: 0, color: '#8060AA' },
+  { name: 'CO最大值', key: 'coMax', unit: 'ppm', yAxisIndex: 1, color: '#38B99B' },
+  { name: 'CO平均值', key: 'coAvg', unit: 'ppm', yAxisIndex: 1, color: '#5578E7' },
+  { name: 'O₂最小值', key: 'o2Min', unit: '%', yAxisIndex: 0, color: '#B9DE2C' },
+  { name: 'O₂平均值', key: 'o2Avg', unit: '%', yAxisIndex: 0, color: '#8583B4' },
+];
+
+const dailyAnalysisSeries = dailyAnalysisSeriesConfig.map(({ name, key, unit, yAxisIndex, color }) => ({
+  name,
+  unit,
+  yAxisIndex,
+  color,
+  data: dailyAnalysisRecords.map((record) => record[key]),
+}));
+
+const dailyAnalysisAxes = [
+  { name: '%', min: 0, max: 25, interval: 5, position: 'left' },
+  { name: 'ppm', min: 0, max: 25, interval: 5, position: 'right' },
+];
+
+function onDailyAnalysisClick(params) {
+  console.log('点击束管日报数据', params);
+}
 </script>
+<style scoped>
+.test-chart-page {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  height: 96%;
+  overflow-y: auto;
+}
+</style>