taosj 3 месяцев назад
Родитель
Сommit
68896c5a4c

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

@@ -1 +1,6 @@
 update public.sys_menu set component='ufo/config/station/cpoint/index' where menu_name = '测点配置';
+
+
+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, '');

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

@@ -11,10 +11,13 @@ import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 import com.ufo.base.framework.aspectj.lang.annotation.Log;
 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.service.IDataShortTermWeatherForecastRawService;
 import com.ufo.base.framework.web.controller.BaseController;
 import com.ufo.base.framework.web.domain.AjaxResult;
@@ -46,6 +49,31 @@ public class DataShortTermWeatherForecastRawController extends BaseController
         return getDataTable(list);
     }
 
+    /**
+     * 统计风玫瑰图数据
+     */
+    @PreAuthorize("@ss.hasPermi('weather:weatherRaw:rose')")
+    @GetMapping("/rosechart")
+    public AjaxResult rosechart(@RequestParam("stationId") Long stationId,
+            @RequestParam("startTime") Long startTime, @RequestParam("endTime") Long endTime)
+    {
+        return success(dataShortTermWeatherForecastRawService.getRoseChartStat(stationId, startTime, endTime));
+    }
+
+    /**
+     * 导出风玫瑰图统计数据
+     */
+    @PreAuthorize("@ss.hasPermi('weather:weatherRaw:rose')")
+    @Log(title = "风玫瑰图", businessType = BusinessType.EXPORT)
+    @GetMapping("/rosechart/export")
+    public void rosechartExport(HttpServletResponse response, @RequestParam("stationId") Long stationId,
+            @RequestParam("startTime") Long startTime, @RequestParam("endTime") Long endTime)
+    {
+        RoseChartResult result = dataShortTermWeatherForecastRawService.getRoseChartStat(stationId, startTime, endTime);
+        ExcelUtil<RoseChartDirectionStat> util = new ExcelUtil<RoseChartDirectionStat>(RoseChartDirectionStat.class);
+        util.exportExcel(response, result.getStats(), "风玫瑰图数据");
+    }
+
     /**
      * 导出短期气象原始数据列表
      */

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

@@ -0,0 +1,80 @@
+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 RoseChartDirectionStat
+{
+    /** 风向方位名称 */
+    @Excel(name = "风向")
+    private String direction;
+
+    /** 该方位记录数 */
+    @Excel(name = "记录数")
+    private Long count;
+
+    /** 频率(%) */
+    @Excel(name = "频率(%)")
+    private Double frequency;
+
+    /** 平均风速(m/s) */
+    @Excel(name = "平均风速(m/s)")
+    private Double avgSpeed;
+
+    public RoseChartDirectionStat()
+    {
+    }
+
+    public RoseChartDirectionStat(String direction, Long count, Double frequency, Double avgSpeed)
+    {
+        this.direction = direction;
+        this.count = count;
+        this.frequency = frequency;
+        this.avgSpeed = avgSpeed;
+    }
+
+    public String getDirection()
+    {
+        return direction;
+    }
+
+    public void setDirection(String direction)
+    {
+        this.direction = direction;
+    }
+
+    public Long getCount()
+    {
+        return count;
+    }
+
+    public void setCount(Long count)
+    {
+        this.count = count;
+    }
+
+    public Double getFrequency()
+    {
+        return frequency;
+    }
+
+    public void setFrequency(Double frequency)
+    {
+        this.frequency = frequency;
+    }
+
+    public Double getAvgSpeed()
+    {
+        return avgSpeed;
+    }
+
+    public void setAvgSpeed(Double avgSpeed)
+    {
+        this.avgSpeed = avgSpeed;
+    }
+}

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

@@ -0,0 +1,90 @@
+package com.ufo.project.forecast.shortterm.weather.domain.vo;
+
+import java.util.List;
+
+/**
+ * 风玫瑰图统计结果
+ *
+ * @author ufo
+ * @date 2026-08-04
+ */
+public class RoseChartResult
+{
+    /** 场站ID */
+    private Long stationId;
+
+    /** 查询到的总记录数 */
+    private Integer totalRecords;
+
+    /** 有效记录数(通过数据清洗) */
+    private Integer validRecords;
+
+    /** 静风记录数 */
+    private Integer calmCount;
+
+    /** 静风百分比(%) */
+    private Double calmPercent;
+
+    /** 16个风向方位统计 */
+    private List<RoseChartDirectionStat> 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 Integer getCalmCount()
+    {
+        return calmCount;
+    }
+
+    public void setCalmCount(Integer calmCount)
+    {
+        this.calmCount = calmCount;
+    }
+
+    public Double getCalmPercent()
+    {
+        return calmPercent;
+    }
+
+    public void setCalmPercent(Double calmPercent)
+    {
+        this.calmPercent = calmPercent;
+    }
+
+    public List<RoseChartDirectionStat> getStats()
+    {
+        return stats;
+    }
+
+    public void setStats(List<RoseChartDirectionStat> stats)
+    {
+        this.stats = stats;
+    }
+}

+ 14 - 3
forecast-backend-server/src/main/java/com/ufo/project/forecast/shortterm/weather/mapper/DataShortTermWeatherForecastRawMapper.java

@@ -1,19 +1,30 @@
 package com.ufo.project.forecast.shortterm.weather.mapper;
 
 import java.util.List;
+import org.apache.ibatis.annotations.Param;
 import com.ufo.project.forecast.shortterm.weather.domain.DataShortTermWeatherForecastRaw;
 
 /**
  * 短期气象原始数据Mapper接口
- * 
+ *
  * @author ufo
  * @date 2026-05-05
  */
-public interface DataShortTermWeatherForecastRawMapper 
+public interface DataShortTermWeatherForecastRawMapper
 {
+    /**
+     * 查询场站指定时间范围内的短期气象原始数据(用于风玫瑰图统计)
+     *
+     * @param stationId 场站ID
+     * @param startTime 预测时间起始值
+     * @param endTime 预测时间结束值
+     * @return 短期气象原始数据集合
+     */
+    public List<DataShortTermWeatherForecastRaw> selectDataShortTermWeatherForecastRawListByTimeRange(@Param("stationId") Long stationId, @Param("startTime") Long startTime, @Param("endTime") Long endTime);
+
     /**
      * 查询短期气象原始数据
-     * 
+     *
      * @param Id 短期气象原始数据主键
      * @return 短期气象原始数据
      */

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

@@ -2,18 +2,29 @@ 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;
 
 /**
  * 短期气象原始数据Service接口
- * 
+ *
  * @author ufo
  * @date 2026-05-05
  */
-public interface IDataShortTermWeatherForecastRawService 
+public interface IDataShortTermWeatherForecastRawService
 {
+    /**
+     * 统计场站指定时间范围内的风玫瑰图数据
+     *
+     * @param stationId 场站ID
+     * @param startTime 预测时间起始值
+     * @param endTime 预测时间结束值
+     * @return 风玫瑰图统计结果
+     */
+    public RoseChartResult getRoseChartStat(Long stationId, Long startTime, Long endTime);
+
     /**
      * 查询短期气象原始数据
-     * 
+     *
      * @param Id 短期气象原始数据主键
      * @return 短期气象原始数据
      */

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

@@ -1,11 +1,16 @@
 package com.ufo.project.forecast.shortterm.weather.service.impl;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
 import java.util.List;
 import com.ufo.base.common.utils.DateUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import com.ufo.project.forecast.shortterm.weather.mapper.DataShortTermWeatherForecastRawMapper;
 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.service.IDataShortTermWeatherForecastRawService;
 
 /**
@@ -20,9 +25,136 @@ public class DataShortTermWeatherForecastRawServiceImpl implements IDataShortTer
     @Autowired
     private DataShortTermWeatherForecastRawMapper dataShortTermWeatherForecastRawMapper;
 
+    /** 16个风向方位名称 */
+    private static final String[] DIRECTIONS = { "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+            "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" };
+
+    /** 每个方位占22.5度 */
+    private static final double SECTOR_DEGREE = 22.5;
+
+    /** 静风风速阈值(m/s) */
+    private static final double CALM_THRESHOLD = 0.5;
+
+    /** 风向有效范围 */
+    private static final double WIND_DIRECTION_MAX = 360.0;
+
+    /** 风速有效范围(m/s) */
+    private static final double WIND_SPEED_MAX = 50.0;
+
+    /**
+     * 统计场站指定时间范围内的风玫瑰图数据
+     *
+     * @param stationId 场站ID
+     * @param startTime 预测时间起始值
+     * @param endTime 预测时间结束值
+     * @return 风玫瑰图统计结果
+     */
+    @Override
+    public RoseChartResult getRoseChartStat(Long stationId, Long startTime, Long endTime)
+    {
+        List<DataShortTermWeatherForecastRaw> list =
+                dataShortTermWeatherForecastRawMapper.selectDataShortTermWeatherForecastRawListByTimeRange(stationId, startTime, endTime);
+
+        long[] counts = new long[16];
+        double[] speedSums = new double[16];
+        int validRecords = 0;
+        int calmCount = 0;
+
+        for (DataShortTermWeatherForecastRaw record : list)
+        {
+            // 风速:优先取ws10,ws10为空或无法解析时取ws100
+            Double ws = parseDouble(record.getWs10());
+            String wdValue = record.getWd10();
+            if (ws == null)
+            {
+                ws = parseDouble(record.getWs100());
+                wdValue = record.getWd100();
+            }
+            // 风向:与风速取用高度对应,取wd10或wd100
+            Double wd = parseDouble(wdValue);
+            // 过滤风向/风速为空或不能转为数字的记录
+            if (wd == null || ws == null)
+            {
+                continue;
+            }
+            // 过滤风向不在 [0,360] 范围的记录
+            if (wd < 0 || wd > WIND_DIRECTION_MAX)
+            {
+                continue;
+            }
+            // 过滤风速不在 [0,50] m/s 范围的记录
+            if (ws < 0 || ws > WIND_SPEED_MAX)
+            {
+                continue;
+            }
+            validRecords++;
+            // 风速小于静风阈值为静风,不计入方位统计
+            if (ws < CALM_THRESHOLD)
+            {
+                calmCount++;
+                continue;
+            }
+            int index = (int) Math.floor(((wd + SECTOR_DEGREE) % WIND_DIRECTION_MAX) / SECTOR_DEGREE);
+            if (index >= 0 && index < DIRECTIONS.length)
+            {
+                counts[index]++;
+                speedSums[index] += ws;
+            }
+        }
+
+        List<RoseChartDirectionStat> stats = new ArrayList<RoseChartDirectionStat>();
+        for (int i = 0; i < DIRECTIONS.length; i++)
+        {
+            double frequency = validRecords > 0 ? counts[i] * 100.0 / validRecords : 0;
+            double avgSpeed = counts[i] > 0 ? speedSums[i] / counts[i] : 0;
+            stats.add(new RoseChartDirectionStat(DIRECTIONS[i], counts[i], round2(frequency), round2(avgSpeed)));
+        }
+
+        RoseChartResult result = new RoseChartResult();
+        result.setStationId(stationId);
+        result.setTotalRecords(list.size());
+        result.setValidRecords(validRecords);
+        result.setCalmCount(calmCount);
+        result.setCalmPercent(round2(validRecords > 0 ? calmCount * 100.0 / validRecords : 0));
+        result.setStats(stats);
+        return result;
+    }
+
+    /**
+     * 将字符串安全转换为Double,空值、'NULL'、'NaN'等脏数据或无法解析时返回null
+     */
+    private Double parseDouble(String value)
+    {
+        if (value == null)
+        {
+            return null;
+        }
+        String v = value.trim();
+        if (v.isEmpty() || "null".equalsIgnoreCase(v) || "nan".equalsIgnoreCase(v))
+        {
+            return null;
+        }
+        try
+        {
+            return Double.parseDouble(v);
+        }
+        catch (NumberFormatException e)
+        {
+            return null;
+        }
+    }
+
+    /**
+     * 保留两位小数(四舍五入)
+     */
+    private double round2(double value)
+    {
+        return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
+    }
+
     /**
      * 查询短期气象原始数据
-     * 
+     *
      * @param Id 短期气象原始数据主键
      * @return 短期气象原始数据
      */

+ 8 - 1
forecast-backend-server/src/main/resources/mybatis/weather/DataShortTermWeatherForecastRawMapper.xml

@@ -144,7 +144,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="ws200 != null  and ws200 != ''"> and ws200 = #{ws200}</if>
         </where>
     </select>
-    
+
+    <select id="selectDataShortTermWeatherForecastRawListByTimeRange" resultMap="DataShortTermWeatherForecastRawResult">
+        <include refid="selectDataShortTermWeatherForecastRawVo"/>
+        where station_id = #{stationId}
+          and forecast_time &gt;= #{startTime}
+          and forecast_time &lt;= #{endTime}
+    </select>
+
     <select id="selectDataShortTermWeatherForecastRawById" parameterType="Long" resultMap="DataShortTermWeatherForecastRawResult">
         <include refid="selectDataShortTermWeatherForecastRawVo"/>
         where _id = #{Id}

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

@@ -42,3 +42,23 @@ export function delWeatherRaw(Id) {
     method: 'delete'
   })
 }
+
+// 查询风玫瑰图统计
+export function getRoseChart(query) {
+  return request({
+    url: '/weather/weatherRaw/rosechart',
+    method: 'get',
+    params: query
+  })
+}
+
+// 导出风玫瑰图统计
+export function exportRoseChart(query) {
+  return request({
+    url: '/weather/weatherRaw/rosechart/export',
+    method: 'get',
+    params: query,
+    responseType: 'blob',
+    timeout: 120000
+  })
+}

+ 325 - 0
forecast-backend-vue/src/views/ufo/electricity/weather/RoseChart.vue

@@ -0,0 +1,325 @@
+<template>
+  <div class="rose-chart-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="rose-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-item label="静风次数">{{ summary.calmCount }}</a-descriptions-item>
+        <a-descriptions-item label="静风占比">{{ summary.calmPercent }}%</a-descriptions-item>
+      </a-descriptions>
+      <a-table
+        :columns="columns"
+        :data-source="detailRows"
+        :loading="loading"
+        :pagination="false"
+        :scroll="{ x: 560, y: 420 }"
+        row-key="direction"
+        size="middle"
+        bordered
+      />
+    </a-card>
+  </div>
+</template>
+
+<script setup name="ElectricityRoseChart">
+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 {getRoseChart, exportRoseChart} from '@/api/weather/weatherRaw';
+import {blobValidate} from '@/utils/bearjia';
+import {normalizeRows, toNumber} from '../components/dataAdapter';
+
+// 16 个风向方位(与后端统计顺序一致,北->顺时针)
+const DIRECTIONS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
+
+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: 'direction', width: 100, align: 'center', fixed: 'left'},
+  {title: '次数', dataIndex: 'count', width: 120, align: 'right'},
+  {title: '频率(%)', dataIndex: 'frequency', width: 120, align: 'right'},
+  {title: '平均风速(m/s)', dataIndex: 'avgSpeed', width: 150, align: 'right'}
+];
+
+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 statMap = new Map((stats || []).map((item) => [item.direction, item]));
+  const data = DIRECTIONS.map((dir) => {
+    const item = statMap.get(dir) || {};
+    return {
+      value: toNumber(item.frequency, 0),
+      direction: dir,
+      count: toNumber(item.count, 0),
+      avgSpeed: toNumber(item.avgSpeed, 0)
+    };
+  });
+  const maxFreq = Math.max(1, ...data.map((d) => d.value));
+  const radiusMax = Math.ceil(maxFreq / 5) * 5;
+  return {
+    tooltip: {
+      trigger: 'item',
+      formatter: (params) => {
+        const d = params.data || {};
+        return `${d.direction}<br/>次数:${d.count}<br/>频率:${d.frequency}%<br/>平均风速:${d.avgSpeed} m/s`;
+      }
+    },
+    angleAxis: {
+      type: 'category',
+      data: DIRECTIONS,
+      startAngle: 90,
+      clockWise: true,
+      boundaryGap: false,
+      splitLine: {show: false},
+      axisTick: {show: false},
+      axisLine: {show: false},
+      axisLabel: {color: '#5a6a85', fontSize: 11, margin: 10}
+    },
+    radiusAxis: {
+      min: 0,
+      max: radiusMax,
+      splitNumber: 4,
+      axisLine: {show: false},
+      axisTick: {show: false},
+      axisLabel: {color: '#5a6a85', fontSize: 11, formatter: '{value}%'},
+      splitLine: {lineStyle: {color: 'rgba(24, 144, 255, 0.12)'}}
+    },
+    polar: {center: ['50%', '55%'], radius: '72%'},
+    series: [{
+      name: '风向频率',
+      type: 'bar',
+      coordinateSystem: 'polar',
+      data,
+      itemStyle: {color: 'rgba(24, 144, 255, 0.7)', borderColor: '#ffffff', borderWidth: 1},
+      emphasis: {itemStyle: {color: 'rgba(24, 144, 255, 0.95)'}}
+    }]
+  };
+};
+
+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 getRoseChart(params);
+    const data = res?.data;
+    if (!data) {
+      summary.value = null;
+      detailRows.value = [];
+      renderChart([]);
+      return;
+    }
+    summary.value = {
+      totalRecords: data.totalRecords,
+      validRecords: data.validRecords,
+      calmCount: data.calmCount,
+      calmPercent: data.calmPercent
+    };
+    const statMap = new Map((data.stats || []).map((item) => [item.direction, item]));
+    detailRows.value = DIRECTIONS.map((dir) => {
+      const item = statMap.get(dir) || {};
+      return {
+        direction: dir,
+        count: toNumber(item.count, 0),
+        frequency: toNumber(item.frequency, 0),
+        avgSpeed: toNumber(item.avgSpeed, 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 exportRoseChart(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>
+.rose-chart-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;
+}
+
+.rose-chart {
+  width: 100%;
+  height: 460px;
+}
+
+.detail-card {
+  .summary {
+    margin-bottom: 16px;
+  }
+}
+</style>