diff --git a/internal/database/models.go b/internal/database/models.go
index f0fee5e..ef8cef5 100644
--- a/internal/database/models.go
+++ b/internal/database/models.go
@@ -2,6 +2,7 @@ package database
import (
"database/sql"
+ "fmt"
"log"
"time"
"weatherstation/pkg/types"
@@ -258,3 +259,100 @@ func buildWeatherDataQuery(interval string) string {
LEFT JOIN rain_sum r ON r.time_group = g.time_group
ORDER BY g.time_group`
}
+
+// GetForecastData 获取指定站点的预报数据(优先最新issued_at)
+func GetForecastData(db *sql.DB, stationID string, startTime, endTime time.Time, provider string) ([]types.ForecastPoint, error) {
+ var query string
+ var args []interface{}
+
+ if provider != "" {
+ // 指定预报提供商
+ query = `
+ WITH latest_forecasts AS (
+ SELECT DISTINCT ON (forecast_time)
+ station_id, provider, issued_at, forecast_time,
+ temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000,
+ wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100
+ FROM forecast_hourly
+ WHERE station_id = $1 AND provider = $2
+ AND forecast_time BETWEEN $3 AND $4
+ ORDER BY forecast_time, issued_at DESC
+ )
+ SELECT
+ to_char(forecast_time, 'YYYY-MM-DD HH24:MI:SS') as date_time,
+ provider,
+ to_char(issued_at, 'YYYY-MM-DD HH24:MI:SS') as issued_at,
+ ROUND(temp_c_x100::numeric / 100.0, 2) as temperature,
+ humidity_pct as humidity,
+ ROUND(pressure_hpa_x100::numeric / 100.0, 2) as pressure,
+ ROUND(wind_speed_ms_x1000::numeric / 1000.0, 2) as wind_speed,
+ wind_dir_deg as wind_direction,
+ ROUND(rain_mm_x1000::numeric / 1000.0, 3) as rainfall,
+ precip_prob_pct as precip_prob,
+ uv_index as uv
+ FROM latest_forecasts
+ ORDER BY forecast_time`
+ args = []interface{}{stationID, provider, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
+ } else {
+ // 不指定预报提供商,取所有
+ query = `
+ WITH latest_forecasts AS (
+ SELECT DISTINCT ON (provider, forecast_time)
+ station_id, provider, issued_at, forecast_time,
+ temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000,
+ wind_dir_deg, rain_mm_x1000, precip_prob_pct, uv_index, pressure_hpa_x100
+ FROM forecast_hourly
+ WHERE station_id = $1 AND forecast_time BETWEEN $2 AND $3
+ ORDER BY provider, forecast_time, issued_at DESC
+ )
+ SELECT
+ to_char(forecast_time, 'YYYY-MM-DD HH24:MI:SS') as date_time,
+ provider,
+ to_char(issued_at, 'YYYY-MM-DD HH24:MI:SS') as issued_at,
+ ROUND(temp_c_x100::numeric / 100.0, 2) as temperature,
+ humidity_pct as humidity,
+ ROUND(pressure_hpa_x100::numeric / 100.0, 2) as pressure,
+ ROUND(wind_speed_ms_x1000::numeric / 1000.0, 2) as wind_speed,
+ wind_dir_deg as wind_direction,
+ ROUND(rain_mm_x1000::numeric / 1000.0, 3) as rainfall,
+ precip_prob_pct as precip_prob,
+ uv_index as uv
+ FROM latest_forecasts
+ ORDER BY forecast_time, provider`
+ args = []interface{}{stationID, startTime.Format("2006-01-02 15:04:05-07"), endTime.Format("2006-01-02 15:04:05-07")}
+ }
+
+ // log.Printf("执行预报数据查询: %s, args: %v", query, args)
+ rows, err := db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询预报数据失败: %v", err)
+ }
+ defer rows.Close()
+
+ var points []types.ForecastPoint
+ for rows.Next() {
+ var point types.ForecastPoint
+ err := rows.Scan(
+ &point.DateTime,
+ &point.Provider,
+ &point.IssuedAt,
+ &point.Temperature,
+ &point.Humidity,
+ &point.Pressure,
+ &point.WindSpeed,
+ &point.WindDir,
+ &point.Rainfall,
+ &point.PrecipProb,
+ &point.UV,
+ )
+ if err != nil {
+ log.Printf("数据扫描错误: %v", err)
+ continue
+ }
+ point.Source = "forecast"
+ points = append(points, point)
+ // log.Printf("成功扫描预报数据: %+v", point)
+ }
+
+ return points, nil
+}
diff --git a/internal/server/gin.go b/internal/server/gin.go
index 6d06da2..3d2429f 100644
--- a/internal/server/gin.go
+++ b/internal/server/gin.go
@@ -36,6 +36,7 @@ func StartGinServer() error {
api.GET("/system/status", systemStatusHandler)
api.GET("/stations", getStationsHandler)
api.GET("/data", getDataHandler)
+ api.GET("/forecast", getForecastHandler)
}
// 获取配置的Web端口
@@ -136,3 +137,60 @@ func getDataHandler(c *gin.Context) {
c.JSON(http.StatusOK, points)
}
+
+// getForecastHandler 处理获取预报数据API请求
+func getForecastHandler(c *gin.Context) {
+ // 获取查询参数
+ stationID := c.Query("station_id")
+ startTime := c.Query("from")
+ endTime := c.Query("to")
+ provider := c.Query("provider")
+
+ if stationID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "缺少station_id参数"})
+ return
+ }
+
+ // 如果没有提供时间范围,则默认查询未来3小时
+ loc, _ := time.LoadLocation("Asia/Shanghai")
+ if loc == nil {
+ loc = time.FixedZone("CST", 8*3600)
+ }
+
+ var start, end time.Time
+ var err error
+
+ if startTime == "" || endTime == "" {
+ // 默认查询未来3小时
+ now := time.Now().In(loc)
+ start = now.Truncate(time.Hour).Add(1 * time.Hour) // 下一个整点开始
+ end = start.Add(3 * time.Hour) // 未来3小时
+ } else {
+ // 解析用户提供的时间
+ start, err = time.ParseInLocation("2006-01-02 15:04:05", startTime, loc)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的开始时间格式"})
+ return
+ }
+
+ end, err = time.ParseInLocation("2006-01-02 15:04:05", endTime, loc)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的结束时间格式"})
+ return
+ }
+ }
+
+ // 获取预报数据
+ // log.Printf("查询预报数据: stationID=%s, provider=%s, start=%s, end=%s", stationID, provider, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"))
+ points, err := database.GetForecastData(database.GetDB(), stationID, start, end, provider)
+ if err != nil {
+ log.Printf("查询预报数据失败: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": fmt.Sprintf("查询预报数据失败: %v", err),
+ })
+ return
+ }
+
+ // log.Printf("查询到预报数据: %d 条", len(points))
+ c.JSON(http.StatusOK, points)
+}
diff --git a/pkg/types/types.go b/pkg/types/types.go
index f930099..b7c8045 100644
--- a/pkg/types/types.go
+++ b/pkg/types/types.go
@@ -39,3 +39,19 @@ type SystemStatus struct {
OnlineDevices int `json:"online_devices"`
ServerTime string `json:"server_time"`
}
+
+// ForecastPoint 预报数据点
+type ForecastPoint struct {
+ DateTime string `json:"date_time"`
+ Provider string `json:"provider"`
+ IssuedAt string `json:"issued_at"`
+ Temperature *float64 `json:"temperature"`
+ Humidity *float64 `json:"humidity"`
+ Pressure *float64 `json:"pressure"`
+ WindSpeed *float64 `json:"wind_speed"`
+ WindDir *float64 `json:"wind_direction"`
+ Rainfall *float64 `json:"rainfall"`
+ PrecipProb *float64 `json:"precip_prob"`
+ UV *float64 `json:"uv"`
+ Source string `json:"source"` // "forecast"
+}
diff --git a/templates/index.html b/templates/index.html
index 68256dd..661b0cc 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -448,6 +448,14 @@
+
+
+
+
+
@@ -674,8 +682,9 @@
// 初始化日期输入
function initializeDateInputs() {
const now = new Date();
- const endDate = new Date(now);
- const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24小时前
+ //const startDate = new Date(now.getTime() - 48 * 60 * 60 * 1000); // 过去48小时
+ const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 过去48小时
+ const endDate = new Date(now.getTime() + 3 * 60 * 60 * 1000); // 未来3小时
document.getElementById('startDate').value = formatDatetimeLocal(startDate);
document.getElementById('endDate').value = formatDatetimeLocal(endDate);
@@ -1155,6 +1164,7 @@
const startTime = document.getElementById('startDate').value;
const endTime = document.getElementById('endDate').value;
const interval = document.getElementById('interval').value;
+ const forecastProvider = document.getElementById('forecastProvider').value;
if (!startTime || !endTime) {
alert('请选择开始和结束时间');
@@ -1162,27 +1172,74 @@
}
try {
- const params = new URLSearchParams({
+ // 查询历史数据
+ const historyParams = new URLSearchParams({
decimal_id: decimalId,
start_time: startTime.replace('T', ' ') + ':00',
end_time: endTime.replace('T', ' ') + ':00',
interval: interval
});
- const response = await fetch(`/api/data?${params}`);
- if (!response.ok) {
- throw new Error('查询失败');
+ const historyResponse = await fetch(`/api/data?${historyParams}`);
+ if (!historyResponse.ok) {
+ throw new Error('查询历史数据失败');
}
- const data = await response.json();
+ const responseData = await historyResponse.json();
+ const historyData = Array.isArray(responseData) ? responseData : [];
- if (data.length === 0) {
+ // 查询预报数据(如果选择了预报提供商且为1小时粒度)
+ let forecastData = [];
+ if (forecastProvider && interval === '1hour') {
+ try {
+ // 将十进制ID转换为站点ID格式
+ const hexID = decimalToHex(decimalId);
+ const stationID = `RS485-${hexID}`;
+
+ // 检查是否查询未来时间(包含未来时间的查询)
+ const now = new Date();
+ const endDateTime = new Date(endTime.replace('T', ' ') + ':00');
+ const isIncludingFuture = endDateTime > now;
+
+ let forecastParams;
+ if (isIncludingFuture) {
+ // 查询包含未来时间:只查询未来部分的预报
+ const futureStartTime = now.toISOString().slice(0, 19).replace('T', ' ');
+ forecastParams = new URLSearchParams({
+ station_id: stationID,
+ from: futureStartTime,
+ to: endTime.replace('T', ' ') + ':00',
+ provider: forecastProvider
+ });
+ } else {
+ // 查询历史时间段:查询当时发布的历史预报
+ forecastParams = new URLSearchParams({
+ station_id: stationID,
+ from: startTime.replace('T', ' ') + ':00',
+ to: endTime.replace('T', ' ') + ':00',
+ provider: forecastProvider
+ });
+ }
+
+ const forecastResponse = await fetch(`/api/forecast?${forecastParams}`);
+ if (forecastResponse.ok) {
+ const responseData = await forecastResponse.json();
+ forecastData = Array.isArray(responseData) ? responseData : [];
+ console.log(`查询到 ${forecastData.length} 条预报数据`);
+ }
+ } catch (forecastError) {
+ console.warn('查询预报数据失败:', forecastError);
+ // 预报查询失败不影响历史数据显示
+ }
+ }
+
+ if (historyData.length === 0 && forecastData.length === 0) {
alert('该时间段内无数据');
return;
}
- displayChart(data);
- displayTable(data);
+ displayChart(historyData, forecastData);
+ displayTable(historyData, forecastData);
// 显示图表和表格
const chartContainer = document.getElementById('chartContainer');
@@ -1198,54 +1255,118 @@
});
} catch (error) {
- console.error('查询历史数据失败:', error);
- alert('查询历史数据失败: ' + error.message);
+ console.error('查询数据失败:', error);
+ alert('查询数据失败: ' + error.message);
}
}
// 显示图表
- function displayChart(data) {
- const labels = data.map(item => item.date_time);
- const temperatures = data.map(item => item.temperature);
- const humidities = data.map(item => item.humidity);
- const rainfalls = data.map(item => item.rainfall);
+ function displayChart(historyData = [], forecastData = []) {
+ // 确保数据是数组
+ historyData = Array.isArray(historyData) ? historyData : [];
+ forecastData = Array.isArray(forecastData) ? forecastData : [];
+
+ // 如果没有任何数据,则不绘制图表
+ if (historyData.length === 0 && forecastData.length === 0) {
+ return;
+ }
+
+ // 合并历史数据和预报数据的时间轴
+ const allLabels = [...new Set([
+ ...historyData.map(item => item.date_time),
+ ...forecastData.map(item => item.date_time)
+ ])].sort();
+
+ // 准备历史数据
+ const historyLabels = historyData.map(item => item.date_time);
+ const historyTemperatures = allLabels.map(label => {
+ const item = historyData.find(d => d.date_time === label);
+ return item ? item.temperature : null;
+ });
+ const historyHumidities = allLabels.map(label => {
+ const item = historyData.find(d => d.date_time === label);
+ return item ? item.humidity : null;
+ });
+ const historyRainfalls = allLabels.map(label => {
+ const item = historyData.find(d => d.date_time === label);
+ return item ? item.rainfall : null;
+ });
+
+ // 准备预报数据
+ const forecastTemperatures = allLabels.map(label => {
+ const item = forecastData.find(d => d.date_time === label);
+ return item && item.temperature !== null ? item.temperature : null;
+ });
+ const forecastRainfalls = allLabels.map(label => {
+ const item = forecastData.find(d => d.date_time === label);
+ return item && item.rainfall !== null ? item.rainfall : null;
+ });
// 销毁旧图表
if (combinedChart) combinedChart.destroy();
+ // 创建数据集
+ const datasets = [
+ {
+ label: '温度 (°C) - 实测',
+ data: historyTemperatures,
+ borderColor: 'rgb(255, 99, 132)',
+ backgroundColor: 'rgba(255, 99, 132, 0.1)',
+ yAxisID: 'y-temperature',
+ tension: 0.4,
+ spanGaps: false
+ },
+ {
+ label: '湿度 (%) - 实测',
+ data: historyHumidities,
+ borderColor: 'rgb(54, 162, 235)',
+ backgroundColor: 'rgba(54, 162, 235, 0.1)',
+ yAxisID: 'y-humidity',
+ tension: 0.4,
+ hidden: true, // 默认隐藏湿度数据
+ spanGaps: false
+ },
+ {
+ label: '雨量 (mm) - 实测',
+ data: historyRainfalls,
+ type: 'bar',
+ backgroundColor: 'rgba(54, 162, 235, 0.6)',
+ borderColor: 'rgb(54, 162, 235)',
+ yAxisID: 'y-rainfall'
+ }
+ ];
+
+ // 添加预报数据集
+ if (forecastData.length > 0) {
+ datasets.push(
+ {
+ label: '温度 (°C) - 预报',
+ data: forecastTemperatures,
+ borderColor: 'rgb(255, 165, 0)',
+ backgroundColor: 'rgba(255, 165, 0, 0.1)',
+ borderDash: [5, 5], // 虚线
+ yAxisID: 'y-temperature',
+ tension: 0.4,
+ spanGaps: false
+ },
+ {
+ label: '雨量 (mm) - 预报',
+ data: forecastRainfalls,
+ type: 'bar',
+ backgroundColor: 'rgba(255, 165, 0, 0.4)',
+ borderColor: 'rgb(255, 165, 0)',
+ yAxisID: 'y-rainfall'
+ }
+ );
+ }
+
// 创建组合图表
const ctx = document.getElementById('combinedChart').getContext('2d');
combinedChart = new Chart(ctx, {
type: 'line',
data: {
- labels: labels,
- datasets: [
- {
- label: '温度 (°C)',
- data: temperatures,
- borderColor: 'rgb(255, 99, 132)',
- backgroundColor: 'rgba(255, 99, 132, 0.1)',
- yAxisID: 'y-temperature',
- tension: 0.4
- },
- {
- label: '湿度 (%)',
- data: humidities,
- borderColor: 'rgb(54, 162, 235)',
- backgroundColor: 'rgba(54, 162, 235, 0.1)',
- yAxisID: 'y-humidity',
- tension: 0.4,
- hidden: true // 默认隐藏湿度数据
- },
- {
- label: '雨量 (mm)',
- data: rainfalls,
- type: 'bar',
- backgroundColor: 'rgba(54, 162, 235, 0.6)',
- borderColor: 'rgb(54, 162, 235)',
- yAxisID: 'y-rainfall'
- }
- ]
+ labels: allLabels,
+ datasets: datasets
},
options: {
responsive: true,
@@ -1297,23 +1418,57 @@
}
// 显示数据表格(按时间倒序)
- function displayTable(data) {
+ function displayTable(historyData = [], forecastData = []) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
- const rows = [...data].reverse();
- rows.forEach(item => {
+ // 确保数据是数组
+ historyData = Array.isArray(historyData) ? historyData : [];
+ forecastData = Array.isArray(forecastData) ? forecastData : [];
+
+ // 合并数据并按时间排序
+ const allData = [];
+
+ // 添加历史数据
+ historyData.forEach(item => {
+ allData.push({
+ ...item,
+ source: '实测'
+ });
+ });
+
+ // 添加预报数据
+ forecastData.forEach(item => {
+ allData.push({
+ ...item,
+ source: '预报',
+ // 预报数据中不包含的字段设为null
+ light: null,
+ wind_speed: item.wind_speed !== null ? item.wind_speed : 0,
+ wind_direction: item.wind_direction !== null ? item.wind_direction : 0
+ });
+ });
+
+ // 按时间倒序排列
+ allData.sort((a, b) => new Date(b.date_time) - new Date(a.date_time));
+
+ allData.forEach(item => {
const row = document.createElement('tr');
+ // 预报数据使用不同的背景色
+ if (item.source === '预报') {
+ row.style.backgroundColor = 'rgba(255, 165, 0, 0.1)';
+ }
+
row.innerHTML = `
-
${item.date_time} |
- ${item.temperature.toFixed(2)} |
- ${item.humidity.toFixed(2)} |
- ${item.pressure.toFixed(2)} |
- ${item.wind_speed.toFixed(2)} |
- ${item.wind_direction.toFixed(2)} |
- ${item.rainfall.toFixed(3)} |
- ${item.light.toFixed(2)} |
- ${item.uv.toFixed(2)} |
+ ${item.date_time} [${item.source}] |
+ ${item.temperature !== null && item.temperature !== undefined ? item.temperature.toFixed(2) : '-'} |
+ ${item.humidity !== null && item.humidity !== undefined ? item.humidity.toFixed(2) : '-'} |
+ ${item.pressure !== null && item.pressure !== undefined ? item.pressure.toFixed(2) : '-'} |
+ ${item.wind_speed !== null && item.wind_speed !== undefined ? item.wind_speed.toFixed(2) : '-'} |
+ ${item.wind_direction !== null && item.wind_direction !== undefined ? item.wind_direction.toFixed(2) : '-'} |
+ ${item.rainfall !== null && item.rainfall !== undefined ? item.rainfall.toFixed(3) : '-'} |
+ ${item.light !== null && item.light !== undefined ? item.light.toFixed(2) : '-'} |
+ ${item.uv !== null && item.uv !== undefined ? item.uv.toFixed(2) : '-'} |
`;
tbody.appendChild(row);
});