Browse Source

[0806] 增加风速曲线图

taosj 3 months ago
parent
commit
94882ce1f5

+ 5 - 0
forecast-backend-server/release/20260806_dp.sql

@@ -4,3 +4,8 @@ update public.sys_menu set component='ufo/config/station/cpoint/index' where men
 INSERT INTO "public"."sys_menu" ("menu_id", "menu_name", "parent_id", "order_num", "path", "component", "query", "route_name", "is_frame", "is_cache", "menu_type", "visible", "status", "perms", "icon", "create_by", "create_time", "update_by", "update_time", "remark") 
 VALUES 
 (2071, '风向玫瑰图', 2004, 3, 'rose', 'ufo/electricity/weather/RoseChart', NULL, 'ElectricityRoseChart', 1, 0, 'C', '0', '0', 'electricity:weather:rose', 'alert', 'admin', '2026-08-04 00:00:00', '', NULL, '');
+
+
+INSERT INTO "public"."sys_menu" ("menu_id", "menu_name", "parent_id", "order_num", "path", "component", "query", "route_name", "is_frame", "is_cache", "menu_type", "visible", "status", "perms", "icon", "create_by", "create_time", "update_by", "update_time", "remark") 
+VALUES 
+(2072, '风速曲线图', 2004, 4, 'wind-speed-curve', 'ufo/electricity/weather/WindSpeedCurve', NULL, 'ElectricityWindSpeedCurve', 1, 0, 'C', '0', '0', 'electricity:weather:wschart', 'alert', 'admin', '2026-08-04 00:00:00', '', NULL, '');

+ 27 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/weather/controller/DataShortTermWeatherForecastRawController.java

@@ -18,6 +18,8 @@ import com.ufo.base.framework.aspectj.lang.enums.BusinessType;
 import com.ufo.project.forecast.shortterm.weather.domain.DataShortTermWeatherForecastRaw;
 import com.ufo.project.forecast.shortterm.weather.domain.vo.RoseChartDirectionStat;
 import com.ufo.project.forecast.shortterm.weather.domain.vo.RoseChartResult;
+import com.ufo.project.forecast.shortterm.weather.domain.vo.WsChartResult;
+import com.ufo.project.forecast.shortterm.weather.domain.vo.WsChartStat;
 import com.ufo.project.forecast.shortterm.weather.service.IDataShortTermWeatherForecastRawService;
 import com.ufo.base.framework.web.controller.BaseController;
 import com.ufo.base.framework.web.domain.AjaxResult;
@@ -74,6 +76,31 @@ public class DataShortTermWeatherForecastRawController extends BaseController
         util.exportExcel(response, result.getStats(), "风玫瑰图数据");
     }
 
+    /**
+     * 统计风速曲线数据
+     */
+    @PreAuthorize("@ss.hasPermi('weather:weatherRaw:wschart')")
+    @GetMapping("/wschart")
+    public AjaxResult wschart(@RequestParam("stationId") Long stationId,
+            @RequestParam("startTime") Long startTime, @RequestParam("endTime") Long endTime)
+    {
+        return success(dataShortTermWeatherForecastRawService.getWsChart(stationId, startTime, endTime));
+    }
+
+    /**
+     * 导出风速曲线数据
+     */
+    @PreAuthorize("@ss.hasPermi('weather:weatherRaw:wschart')")
+    @Log(title = "风速曲线", businessType = BusinessType.EXPORT)
+    @GetMapping("/wschart/export")
+    public void wschartExport(HttpServletResponse response, @RequestParam("stationId") Long stationId,
+            @RequestParam("startTime") Long startTime, @RequestParam("endTime") Long endTime)
+    {
+        WsChartResult result = dataShortTermWeatherForecastRawService.getWsChart(stationId, startTime, endTime);
+        ExcelUtil<WsChartStat> util = new ExcelUtil<WsChartStat>(WsChartStat.class);
+        util.exportExcel(response, result.getStats(), "风速曲线数据");
+    }
+
     /**
      * 导出短期气象原始数据列表
      */

+ 64 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/weather/domain/vo/WsChartResult.java

@@ -0,0 +1,64 @@
+package com.ufo.project.forecast.shortterm.weather.domain.vo;
+
+import java.util.List;
+
+/**
+ * 风速曲线图统计结果
+ *
+ * @author ufo
+ * @date 2026-08-04
+ */
+public class WsChartResult
+{
+    /** 场站ID */
+    private Long stationId;
+
+    /** 查询到的总记录数 */
+    private Integer totalRecords;
+
+    /** 有效记录数(通过数据清洗) */
+    private Integer validRecords;
+
+    /** 风速曲线数据点 */
+    private List<WsChartStat> stats;
+
+    public Long getStationId()
+    {
+        return stationId;
+    }
+
+    public void setStationId(Long stationId)
+    {
+        this.stationId = stationId;
+    }
+
+    public Integer getTotalRecords()
+    {
+        return totalRecords;
+    }
+
+    public void setTotalRecords(Integer totalRecords)
+    {
+        this.totalRecords = totalRecords;
+    }
+
+    public Integer getValidRecords()
+    {
+        return validRecords;
+    }
+
+    public void setValidRecords(Integer validRecords)
+    {
+        this.validRecords = validRecords;
+    }
+
+    public List<WsChartStat> getStats()
+    {
+        return stats;
+    }
+
+    public void setStats(List<WsChartStat> stats)
+    {
+        this.stats = stats;
+    }
+}

+ 50 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/weather/domain/vo/WsChartStat.java

@@ -0,0 +1,50 @@
+package com.ufo.project.forecast.shortterm.weather.domain.vo;
+
+import com.ufo.base.framework.aspectj.lang.annotation.Excel;
+
+/**
+ * 风速曲线图单点数据
+ *
+ * @author ufo
+ * @date 2026-08-04
+ */
+public class WsChartStat
+{
+    /** 预测时间(yyyy-MM-dd HH:mm:ss) */
+    @Excel(name = "时间")
+    private String time;
+
+    /** 风速(m/s) */
+    @Excel(name = "风速(m/s)")
+    private Double ws;
+
+    public WsChartStat()
+    {
+    }
+
+    public WsChartStat(String time, Double ws)
+    {
+        this.time = time;
+        this.ws = ws;
+    }
+
+    public String getTime()
+    {
+        return time;
+    }
+
+    public void setTime(String time)
+    {
+        this.time = time;
+    }
+
+    public Double getWs()
+    {
+        return ws;
+    }
+
+    public void setWs(Double ws)
+    {
+        this.ws = ws;
+    }
+}

+ 11 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/weather/service/IDataShortTermWeatherForecastRawService.java

@@ -3,6 +3,7 @@ package com.ufo.project.forecast.shortterm.weather.service;
 import java.util.List;
 import com.ufo.project.forecast.shortterm.weather.domain.DataShortTermWeatherForecastRaw;
 import com.ufo.project.forecast.shortterm.weather.domain.vo.RoseChartResult;
+import com.ufo.project.forecast.shortterm.weather.domain.vo.WsChartResult;
 
 /**
  * 短期气象原始数据Service接口
@@ -22,6 +23,16 @@ public interface IDataShortTermWeatherForecastRawService
      */
     public RoseChartResult getRoseChartStat(Long stationId, Long startTime, Long endTime);
 
+    /**
+     * 统计场站指定时间范围内的风速曲线数据
+     *
+     * @param stationId 场站ID
+     * @param startTime 预测时间起始值
+     * @param endTime 预测时间结束值
+     * @return 风速曲线统计结果
+     */
+    public WsChartResult getWsChart(Long stationId, Long startTime, Long endTime);
+
     /**
      * 查询短期气象原始数据
      *

+ 57 - 0
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/weather/service/impl/DataShortTermWeatherForecastRawServiceImpl.java

@@ -3,6 +3,7 @@ package com.ufo.project.forecast.shortterm.weather.service.impl;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 import com.ufo.base.common.utils.DateUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -11,6 +12,8 @@ import com.ufo.project.forecast.shortterm.weather.mapper.DataShortTermWeatherFor
 import com.ufo.project.forecast.shortterm.weather.domain.DataShortTermWeatherForecastRaw;
 import com.ufo.project.forecast.shortterm.weather.domain.vo.RoseChartDirectionStat;
 import com.ufo.project.forecast.shortterm.weather.domain.vo.RoseChartResult;
+import com.ufo.project.forecast.shortterm.weather.domain.vo.WsChartResult;
+import com.ufo.project.forecast.shortterm.weather.domain.vo.WsChartStat;
 import com.ufo.project.forecast.shortterm.weather.service.IDataShortTermWeatherForecastRawService;
 
 /**
@@ -120,6 +123,60 @@ public class DataShortTermWeatherForecastRawServiceImpl implements IDataShortTer
         return result;
     }
 
+    /**
+     * 统计场站指定时间范围内的风速曲线数据
+     *
+     * @param stationId 场站ID
+     * @param startTime 预测时间起始值
+     * @param endTime 预测时间结束值
+     * @return 风速曲线统计结果
+     */
+    @Override
+    public WsChartResult getWsChart(Long stationId, Long startTime, Long endTime)
+    {
+        List<DataShortTermWeatherForecastRaw> list =
+                dataShortTermWeatherForecastRawMapper.selectDataShortTermWeatherForecastRawListByTimeRange(stationId, startTime, endTime);
+
+        List<WsChartStat> stats = new ArrayList<WsChartStat>();
+        int validRecords = 0;
+
+        for (DataShortTermWeatherForecastRaw record : list)
+        {
+            // 风速:优先取ws10,ws10为空或无法解析时取ws100
+            Double ws = parseDouble(record.getWs10());
+            if (ws == null)
+            {
+                ws = parseDouble(record.getWs100());
+            }
+            // 过滤风速为空或不能转为数字的记录
+            if (ws == null)
+            {
+                continue;
+            }
+            // 过滤风速不在 [0,50] m/s 范围的记录
+            if (ws < 0 || ws > WIND_SPEED_MAX)
+            {
+                continue;
+            }
+            // 过滤预测时间为空的记录
+            if (record.getForecastTime() == null)
+            {
+                continue;
+            }
+            validRecords++;
+            String time = DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS,
+                    new Date(record.getForecastTime() * 1000L));
+            stats.add(new WsChartStat(time, round2(ws)));
+        }
+
+        WsChartResult result = new WsChartResult();
+        result.setStationId(stationId);
+        result.setTotalRecords(list.size());
+        result.setValidRecords(validRecords);
+        result.setStats(stats);
+        return result;
+    }
+
     /**
      * 将字符串安全转换为Double,空值、'NULL'、'NaN'等脏数据或无法解析时返回null
      */

+ 20 - 0
forecast-backend-vue/src/api/weather/weatherRaw.js

@@ -62,3 +62,23 @@ export function exportRoseChart(query) {
     timeout: 120000
   })
 }
+
+// 查询风速曲线数据
+export function getWsChart(query) {
+  return request({
+    url: '/weather/weatherRaw/wschart',
+    method: 'get',
+    params: query
+  })
+}
+
+// 导出风速曲线数据
+export function exportWsChart(query) {
+  return request({
+    url: '/weather/weatherRaw/wschart/export',
+    method: 'get',
+    params: query,
+    responseType: 'blob',
+    timeout: 120000
+  })
+}

+ 12 - 0
forecast-backend-vue/src/router/electricity.js

@@ -36,6 +36,18 @@ const electricityRoutes = [
         component: () => import('@/views/ufo/electricity/weather/DisasterWarningMap.vue'),
         meta: {title: '灾害预警地图', icon: 'AlertOutlined'}
       },
+      {
+        path: 'weather/rose',
+        name: 'ElectricityRoseChart',
+        component: () => import('@/views/ufo/electricity/weather/RoseChart.vue'),
+        meta: {title: '风向预测玫瑰图', icon: 'AlertOutlined'}
+      },
+      {
+        path: 'weather/wind-speed-curve',
+        name: 'ElectricityWindSpeedCurve',
+        component: () => import('@/views/ufo/electricity/weather/WindSpeedCurve.vue'),
+        meta: {title: '风速曲线图', icon: 'LineChartOutlined'}
+      },
       {
         path: 'forecast/multi-model',
         name: 'ElectricityMultiModelForecast',

+ 296 - 0
forecast-backend-vue/src/views/ufo/electricity/weather/WindSpeedCurve.vue

@@ -0,0 +1,296 @@
+<template>
+  <div class="wind-speed-curve-page">
+    <a-card :bordered="false" class="query-card">
+      <a-form :model="query" layout="inline" class="query-form">
+        <a-form-item label="场站">
+          <a-select
+            v-model:value="query.stationId"
+            :options="stationOptions"
+            show-search
+            :filter-option="filterOption"
+            placeholder="请选择场站"
+            class="station-control"
+          />
+        </a-form-item>
+        <a-form-item label="时间">
+          <a-range-picker
+            v-model:value="query.timeRange"
+            value-format="YYYY-MM-DD"
+            class="time-control"
+          />
+        </a-form-item>
+        <a-form-item class="query-actions">
+          <a-space>
+            <a-button type="primary" :loading="loading" @click="handleQuery">确认</a-button>
+            <a-button :disabled="!hasData" @click="handleExport">导出</a-button>
+          </a-space>
+        </a-form-item>
+      </a-form>
+    </a-card>
+
+    <a-card :bordered="false" class="chart-card" title="风速曲线图">
+      <div ref="chartRef" class="curve-chart" />
+    </a-card>
+
+    <a-card :bordered="false" class="detail-card" title="明细数据">
+      <a-descriptions v-if="summary" size="small" :column="4" bordered class="summary">
+        <a-descriptions-item label="总记录数">{{ summary.totalRecords }}</a-descriptions-item>
+        <a-descriptions-item label="有效记录数">{{ summary.validRecords }}</a-descriptions-item>
+      </a-descriptions>
+      <a-table
+        :columns="columns"
+        :data-source="detailRows"
+        :loading="loading"
+        :pagination="pagination"
+        :scroll="{ x: 500, y: 480 }"
+        row-key="key"
+        size="middle"
+        bordered
+      />
+    </a-card>
+  </div>
+</template>
+
+<script setup name="ElectricityWindSpeedCurve">
+import {computed, nextTick, onBeforeUnmount, onMounted, reactive, ref} from 'vue';
+import * as echarts from 'echarts';
+import dayjs from 'dayjs';
+import {message} from 'ant-design-vue';
+import {saveAs} from 'file-saver';
+import {listStation} from '@/api/config/station';
+import {getWsChart, exportWsChart} from '@/api/weather/weatherRaw';
+import {blobValidate} from '@/utils/bearjia';
+import {normalizeRows, toNumber} from '../components/dataAdapter';
+
+const loading = ref(false);
+const stationOptions = ref([]);
+const chartRef = ref();
+const detailRows = ref([]);
+const summary = ref(null);
+
+let chartInstance = null;
+
+const query = reactive({
+  stationId: undefined,
+  timeRange: [
+    dayjs().subtract(3, 'day').format('YYYY-MM-DD'),
+    dayjs().add(3, 'day').format('YYYY-MM-DD')
+  ]
+});
+
+const hasData = computed(() => !!summary.value && summary.value.totalRecords > 0);
+
+const columns = [
+  {title: '时间', dataIndex: 'time', width: 180, align: 'center', fixed: 'left'},
+  {title: '风速(m/s)', dataIndex: 'ws', width: 150, align: 'right'}
+];
+
+const pagination = {
+  pageSize: 20,
+  showSizeChanger: true,
+  showTotal: (total) => `共 ${total} 条`
+};
+
+const filterOption = (input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase());
+
+const toTimeParams = () => {
+  const [start, end] = query.timeRange || [];
+  return {
+    stationId: query.stationId,
+    startTime: dayjs(start).startOf('day').unix(),
+    endTime: dayjs(end).endOf('day').unix()
+  };
+};
+
+const loadStations = async () => {
+  const res = await listStation({pageNum: 1, pageSize: 500});
+  const list = normalizeRows(res);
+  stationOptions.value = list.map((item) => ({
+    label: item.stationName || item.name || item.id,
+    value: item.id ?? item.stationId
+  }));
+  // 自动选中第一个场站
+  if (stationOptions.value.length && !query.stationId) {
+    query.stationId = stationOptions.value[0].value;
+  }
+};
+
+const buildOption = (stats) => {
+  const rows = (stats || []).map((item) => ({time: item.time, ws: toNumber(item.ws, 0)}));
+  return {
+    tooltip: {trigger: 'axis'},
+    legend: {top: 0, left: 'center', data: ['风速']},
+    grid: {left: 60, right: 24, top: 46, bottom: 60},
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: rows.map((item) => item.time),
+      axisLabel: {color: '#5a6a85'}
+    },
+    yAxis: {
+      type: 'value',
+      name: '风速(m/s)',
+      splitLine: {lineStyle: {color: '#e6eef8'}},
+      axisLabel: {color: '#5a6a85'}
+    },
+    dataZoom: [
+      {type: 'inside', start: 0, end: 100},
+      {type: 'slider', height: 18, bottom: 12, start: 0, end: 100}
+    ],
+    series: [{
+      name: '风速',
+      type: 'line',
+      smooth: true,
+      showSymbol: false,
+      data: rows.map((item) => item.ws)
+    }]
+  };
+};
+
+const renderChart = async (stats) => {
+  await nextTick();
+  if (!chartRef.value) return;
+  if (!chartInstance) {
+    chartInstance = echarts.init(chartRef.value);
+  }
+  chartInstance.setOption(buildOption(stats), true);
+};
+
+const resizeChart = () => {
+  chartInstance?.resize();
+};
+
+const handleQuery = async () => {
+  if (!query.stationId) {
+    message.warning('请先选择场站');
+    return;
+  }
+  const params = toTimeParams();
+  if (!params.startTime || !params.endTime) {
+    message.warning('请选择时间范围');
+    return;
+  }
+  loading.value = true;
+  try {
+    const res = await getWsChart(params);
+    const data = res?.data;
+    if (!data) {
+      summary.value = null;
+      detailRows.value = [];
+      renderChart([]);
+      return;
+    }
+    summary.value = {
+      totalRecords: data.totalRecords,
+      validRecords: data.validRecords
+    };
+    detailRows.value = (data.stats || []).map((item, index) => ({
+      key: index,
+      time: item.time,
+      ws: toNumber(item.ws, 0)
+    }));
+    renderChart(data.stats || []);
+  } finally {
+    loading.value = false;
+  }
+};
+
+const handleExport = async () => {
+  if (!query.stationId) {
+    message.warning('请先选择场站');
+    return;
+  }
+  const params = toTimeParams();
+  if (!params.startTime || !params.endTime) {
+    message.warning('请选择时间范围');
+    return;
+  }
+  const station = stationOptions.value.find((item) => String(item.value) === String(query.stationId));
+  const [start, end] = query.timeRange || [];
+  try {
+    const res = await exportWsChart(params);
+    const isFile = await blobValidate(res);
+    if (isFile) {
+      saveAs(new Blob([res]), `风速曲线_${station?.label || query.stationId}_${start}_${end}.xlsx`);
+      message.success('导出成功');
+    } else {
+      const text = await res.text();
+      let msg = '导出失败';
+      try {
+        msg = JSON.parse(text)?.msg || msg;
+      } catch (e) {
+        /* ignore */
+      }
+      message.error(msg);
+    }
+  } catch (error) {
+    console.error('导出失败:', error);
+    message.error('导出失败');
+  }
+};
+
+onMounted(async () => {
+  window.addEventListener('resize', resizeChart);
+  await loadStations();
+  await handleQuery();
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', resizeChart);
+  chartInstance?.dispose();
+  chartInstance = null;
+});
+</script>
+
+<style lang="less" scoped>
+.wind-speed-curve-page {
+  padding: 16px;
+}
+
+.query-card {
+  margin-bottom: 16px;
+}
+
+.query-form {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px 16px;
+  align-items: flex-start;
+
+  :deep(.ant-form-item) {
+    margin: 0;
+  }
+
+  :deep(.ant-form-item-label) {
+    min-width: 48px;
+    text-align: left;
+  }
+}
+
+.station-control {
+  width: 240px;
+}
+
+.time-control {
+  width: 300px;
+}
+
+.query-actions {
+  margin-left: auto !important;
+}
+
+.chart-card {
+  margin-bottom: 16px;
+}
+
+.curve-chart {
+  width: 100%;
+  height: 460px;
+}
+
+.detail-card {
+  .summary {
+    margin-bottom: 16px;
+  }
+}
+</style>