diff --git a/cmd/service-api/main.go b/cmd/service-api/main.go index 973b029..3c99f7a 100644 --- a/cmd/service-api/main.go +++ b/cmd/service-api/main.go @@ -8,7 +8,14 @@ import ( func main() { server.SetupLogger() - if err := server.StartGinServer(); err != nil { - log.Fatalf("service-api failed: %v", err) + // 同时在配置端口与 8888 端口各启动一个 Gin 服务 + go func() { + if err := server.StartGinServer(); err != nil { + log.Fatalf("service-api (config port) failed: %v", err) + } + }() + // Bigscreen 专用:在 10008 端口将根路径指向大屏页面 + if err := server.StartBigscreenServerOn(10008); err != nil { + log.Fatalf("service-api (10008 bigscreen) failed: %v", err) } } diff --git a/config.yaml b/config.yaml index 889bbce..89a9734 100644 --- a/config.yaml +++ b/config.yaml @@ -2,10 +2,10 @@ server: udp_port: 10006 database: - host: "8.134.185.53" - port: 5432 + host: "127.0.0.1" + port: 15432 user: "yarnom" - password: "root" + password: "fKvVq6SHjLD2bl" dbname: "weatherdb" sslmode: "disable" @@ -36,9 +36,9 @@ radar: x: 104 mysql: - host: "127.0.0.1" + host: "8.134.185.53" port: 3306 user: "remote" - password: "your_password" + password: "root" dbname: "rtk_data" params: "parseTime=true&loc=Asia%2FShanghai" diff --git a/internal/server/gin.go b/internal/server/gin.go index 751e887..3fe8e39 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -15,8 +15,8 @@ import ( "github.com/gin-gonic/gin" ) -// StartGinServer 启动Gin Web服务器 -func StartGinServer() error { +// newGinEngine 统一创建和配置 Gin 引擎(模板/静态/路由) +func newGinEngine() *gin.Engine { // 设置Gin模式 gin.SetMode(gin.ReleaseMode) @@ -109,6 +109,7 @@ func StartGinServer() error { // 路由设置 r.GET("/", indexHandler) + r.GET("/bigscreen", bigscreenHandler) r.GET("/radar/nanning", radarNanningHandler) r.GET("/radar/guangzhou", radarGuangzhouHandler) r.GET("/radar/panyu", radarPanyuHandler) @@ -140,18 +141,150 @@ func StartGinServer() error { api.GET("/rain/times", rainTileTimesHandler) api.GET("/rain/tiles_at", rainTilesAtHandler) } + return r +} +// StartGinServer 启动Gin Web服务器(配置端口) +func StartGinServer() error { + r := newGinEngine() // 获取配置的Web端口 port := config.GetConfig().Server.WebPort if port == 0 { port = 10003 // 默认端口 } - - // 启动服务器 fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port) return r.Run(fmt.Sprintf(":%d", port)) } +// StartGinServerOn 在指定端口启动一个独立的 Gin 实例 +func StartGinServerOn(port int) error { + r := newGinEngine() + if port <= 0 { + port = 10008 + } + fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port) + return r.Run(fmt.Sprintf(":%d", port)) +} + +// StartBigscreenServerOn 在指定端口启动以大屏为根路径的 Gin 实例(/ -> bigscreen) +func StartBigscreenServerOn(port int) error { + // 独立构建一个以大屏为根路径的引擎,避免与普通引擎重复注册根路由 + gin.SetMode(gin.ReleaseMode) + r := gin.Default() + + exe, _ := os.Executable() + exeDir := filepath.Dir(exe) + + // 模板 + candidatesTpl := []string{ + filepath.Join(exeDir, "templates", "*"), + filepath.Join(exeDir, "..", "templates", "*"), + filepath.Join("templates", "*"), + filepath.Join("..", "templates", "*"), + } + var tplGlob string + for _, c := range candidatesTpl { + base := filepath.Dir(c) + if st, err := os.Stat(base); err == nil && st.IsDir() { + tplGlob = c + break + } + } + if tplGlob == "" { + tplGlob = filepath.Join("templates", "*") + } + r.LoadHTMLGlob(tplGlob) + + // 静态资源 + candidatesStatic := []string{ + filepath.Join(exeDir, "static"), + filepath.Join(exeDir, "..", "static"), + "./static", + "../static", + } + staticDir := candidatesStatic[0] + for _, c := range candidatesStatic { + if st, err := os.Stat(c); err == nil && st.IsDir() { + staticDir = c + break + } + } + r.Static("/static", staticDir) + + // SPA 资源 + r.GET("/ui/*filepath", func(c *gin.Context) { + requested := c.Param("filepath") + if requested == "" || requested == "/" { + candidates := []string{ + filepath.Join(exeDir, "core/frontend/dist/ui/index.html"), + filepath.Join(exeDir, "..", "core/frontend/dist/ui/index.html"), + "./core/frontend/dist/ui/index.html", + "../core/frontend/dist/ui/index.html", + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + c.File(p) + return + } + } + c.String(http.StatusNotFound, "ui not found") + return + } + baseDirCandidates := []string{ + filepath.Join(exeDir, "core/frontend/dist/ui"), + filepath.Join(exeDir, "..", "core/frontend/dist/ui"), + "./core/frontend/dist/ui", + "../core/frontend/dist/ui", + } + baseDir := baseDirCandidates[0] + for _, d := range baseDirCandidates { + if st, err := os.Stat(d); err == nil && st.IsDir() { + baseDir = d + break + } + } + full := baseDir + requested + if _, err := os.Stat(full); err == nil { + c.File(full) + return + } + c.File(filepath.Join(baseDir, "index.html")) + }) + + // 根路径 -> 大屏 + r.GET("/", bigscreenHandler) + + // API 路由 + api := r.Group("/api") + { + api.GET("/system/status", systemStatusHandler) + api.GET("/stations", getStationsHandler) + api.GET("/data", getDataHandler) + api.GET("/forecast", getForecastHandler) + api.GET("/radar/latest", latestRadarTileHandler) + api.GET("/radar/at", radarTileAtHandler) + api.GET("/radar/nearest", nearestRadarTileHandler) + api.GET("/radar/times", radarTileTimesHandler) + api.GET("/radar/tiles_at", radarTilesAtHandler) + api.GET("/radar/weather_latest", latestRadarWeatherHandler) + api.GET("/radar/weather_at", radarWeatherAtHandler) + api.GET("/radar/weather_aliases", radarWeatherAliasesHandler) + api.GET("/radar/aliases", radarConfigAliasesHandler) + api.GET("/radar/weather_nearest", radarWeatherNearestHandler) + api.GET("/rain/latest", latestRainTileHandler) + api.GET("/rain/at", rainTileAtHandler) + api.GET("/rain/nearest", nearestRainTileHandler) + api.GET("/rain/times", rainTileTimesHandler) + api.GET("/rain/tiles_at", rainTilesAtHandler) + } + + if port <= 0 { + port = 10008 + } + fmt.Printf("Gin Bigscreen 服务器启动,监听端口 %d...\n", port) + return r.Run(fmt.Sprintf(":%d", port)) +} + // indexHandler 处理主页请求 func indexHandler(c *gin.Context) { data := types.PageData{ @@ -163,6 +296,17 @@ func indexHandler(c *gin.Context) { c.HTML(http.StatusOK, "index.html", data) } +// bigscreenHandler 大屏演示页 +func bigscreenHandler(c *gin.Context) { + data := types.PageData{ + Title: "英卓大屏", + ServerTime: time.Now().Format("2006-01-02 15:04:05"), + OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()), + TiandituKey: "0c260b8a094a4e0bc507808812cefdac", + } + c.HTML(http.StatusOK, "bigscreen.html", data) +} + // radarNanningHandler 南宁雷达站占位页 func radarNanningHandler(c *gin.Context) { data := types.PageData{ diff --git a/static/js/app.js b/static/js/app.js index 4c5d231..d7c2ef6 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -12,6 +12,29 @@ const WeatherApp = { setInterval(() => this.updateOnlineDevices(), 30000); this.bindUI(); window.addEventListener('query-history-data', () => this.queryHistoryData()); + // 大屏:每10分钟刷新一次雷达/雨量瓦片时次并更新显示 + if (window.IS_BIGSCREEN) { + // 防抖锁,避免重复请求 + this._autoRefreshing = false; + setInterval(async () => { + try { + // 刷新雷达/雨量瓦片时次 + if (WeatherMap && typeof WeatherMap.reloadTileTimesAndShow === 'function') { + const prodSel = document.getElementById('tileProduct'); + const prod = prodSel ? prodSel.value : WeatherMap.tileProduct; + if (prod && prod !== 'none') { + WeatherMap.reloadTileTimesAndShow(); + } + } + // 刷新图表/表格(基于当前控件选择) + const sid = (document.getElementById('stationInput')?.value || '').trim(); + if (!this._autoRefreshing && sid) { + this._autoRefreshing = true; + try { await this.queryHistoryData(); } finally { this._autoRefreshing = false; } + } + } catch (e) { console.warn('自动刷新失败', e); } + }, 10 * 60 * 1000); + } }, bindUI() { @@ -144,10 +167,10 @@ const WeatherApp = { return; } - const startTime = document.getElementById('startDate').value; - const endTime = document.getElementById('endDate').value; - const interval = document.getElementById('interval').value; - const forecastProvider = document.getElementById('forecastProvider').value; + const startTime = document.getElementById('startDate')?.value || ''; + const endTime = document.getElementById('endDate')?.value || ''; + const interval = document.getElementById('interval')?.value || '1hour'; + const forecastProvider = document.getElementById('forecastProvider')?.value || ''; if (!startTime || !endTime) { alert('请选择开始和结束时间'); return; @@ -212,17 +235,9 @@ const WeatherApp = { const station = (WeatherMap.stations || []).find(s => s.decimal_id == decimalId); const stationInfoTitle = document.getElementById('stationInfoTitle'); if (stationInfoTitle) { - if (station) { - stationInfoTitle.innerHTML = ` - - ${station.location || '未知位置'} · - 编号 ${decimalId} · - 坐标 ${station.latitude ? station.latitude.toFixed(6) : '未知'}, ${station.longitude ? station.longitude.toFixed(6) : '未知'} - - `; - } else { - stationInfoTitle.innerHTML = `编号 ${decimalId}`; - } + // 仅展示站点位置(location);无有效值时保持现有文本不变 + const loc = station?.location || ''; + if (loc) stationInfoTitle.textContent = loc; } if (!WeatherMap.isMapCollapsed) WeatherMap.toggleMap(); @@ -242,6 +257,9 @@ const WeatherApp = { if (legendMode) { WeatherChart.applyLegendMode(legendMode.value); } + + // 更新大屏摘要区域(未来降雨与过去准确率) + this.updateSummaryPanel(historyData, forecastData); } catch (error) { console.error('查询数据失败:', error); alert('查询数据失败: ' + error.message); @@ -254,3 +272,56 @@ window.WeatherApp = WeatherApp; document.addEventListener('DOMContentLoaded', () => { WeatherApp.init(); }); + +// 扩展:用于大屏摘要展示 +WeatherApp.updateSummaryPanel = function(historyData, forecastData){ + try{ + const elFuture = document.getElementById('futureRainSummary'); + const elAcc = document.getElementById('pastAccuracySummary'); + if (!elFuture && !elAcc) return; + + // --- 未来1~3小时降雨 --- + const fmt = (n)=> (n==null||isNaN(Number(n))) ? '--' : Number(n).toFixed(1); + const pad2 = (n)=> String(n).padStart(2,'0'); + const fmtDT = (d)=> `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:00:00`; + const now = new Date(); + const ceilHour = (d)=>{ const t = new Date(d); if (t.getMinutes()||t.getSeconds()||t.getMilliseconds()) t.setHours(t.getHours()+1); t.setMinutes(0,0,0); return t; }; + const t1 = ceilHour(now), t2 = new Date(t1.getTime()+3600*1000), t3 = new Date(t1.getTime()+2*3600*1000); + const pickBestAt = (dtStr)=>{ + const cand = (forecastData||[]).filter(x=>x.date_time===dtStr && x.rainfall!=null); + if (cand.length===0) return null; + // 选择最小 lead_hours 的记录 + cand.sort((a,b)=> (a.lead_hours??99)-(b.lead_hours??99)); + return cand[0].rainfall; + }; + const r1 = pickBestAt(fmtDT(t1)); + const r2 = pickBestAt(fmtDT(t2)); + const r3 = pickBestAt(fmtDT(t3)); + const futureSum = [r1,r2,r3].reduce((s,v)=> s + (v!=null?Number(v):0), 0); + if (elFuture) elFuture.textContent = `未来1~3小时降雨 ${fmt(futureSum)} 毫米`; + + // --- 过去预报准确率(按小时分档 [0,5), [5,10), [10,∞))--- + const bucketOf = (mm)=>{ + if (mm==null || isNaN(Number(mm))) return null; + const v = Math.max(0, Number(mm)); + if (v < 5) return 0; if (v < 10) return 1; return 2; + }; + const rainActual = new Map(); + (historyData||[]).forEach(it=>{ if (it && it.date_time) rainActual.set(it.date_time, it.rainfall); }); + const tally = (lead)=>{ + let correct=0,total=0; + (forecastData||[]).forEach(f=>{ + if (f.lead_hours!==lead) return; + const a = rainActual.get(f.date_time); + if (a==null) return; + const ba=bucketOf(a), bf=bucketOf(f.rainfall); + if (ba==null || bf==null) return; + total += 1; if (ba===bf) correct += 1; + }); + return {correct,total}; + }; + const rH1 = tally(1), rH2 = tally(2), rH3 = tally(3); + const pct = (r)=> r.total>0 ? `${(r.correct/r.total*100).toFixed(1)}%` : '--'; + if (elAcc) elAcc.textContent = `过去预报准确率 +1h: ${pct(rH1)} +2h: ${pct(rH2)} +3h: ${pct(rH3)}`; + }catch(e){ console.warn('更新摘要失败', e); } +} diff --git a/static/js/weather-app.js b/static/js/weather-app.js index 04c5bb0..ea5d618 100644 --- a/static/js/weather-app.js +++ b/static/js/weather-app.js @@ -704,11 +704,11 @@ const WeatherMap = { const toggleBtn = document.getElementById('toggleMapBtn'); if (this.isMapCollapsed) { - mapContainer.classList.add('collapsed'); - toggleBtn.textContent = '展开地图'; + mapContainer?.classList.add('collapsed'); + if (toggleBtn) toggleBtn.textContent = '展开地图'; } else { - mapContainer.classList.remove('collapsed'); - toggleBtn.textContent = '折叠地图'; + mapContainer?.classList.remove('collapsed'); + if (toggleBtn) toggleBtn.textContent = '折叠地图'; } setTimeout(() => { @@ -720,15 +720,23 @@ const WeatherMap = { async loadStations() { try { const response = await fetch('/api/stations'); - this.stations = await response.json(); + if (!response.ok) { + console.warn('获取站点列表失败,HTTP', response.status); + this.stations = []; + } else { + const data = await response.json(); + this.stations = Array.isArray(data) ? data : []; + } // 更新WH65LP设备数量 - const wh65lpDevices = this.stations.filter(station => station.device_type === 'WH65LP'); - document.getElementById('wh65lpCount').textContent = wh65lpDevices.length; + const wh65lpDevices = (this.stations || []).filter(station => station.device_type === 'WH65LP'); + const cntEl = document.getElementById('wh65lpCount'); + if (cntEl) cntEl.textContent = wh65lpDevices.length; this.displayStationsOnMap(); } catch (error) { console.error('加载站点失败:', error); + this.stations = []; } }, diff --git a/static/js/weather-chart.js b/static/js/weather-chart.js index c27d9da..a2bd91e 100644 --- a/static/js/weather-chart.js +++ b/static/js/weather-chart.js @@ -281,34 +281,59 @@ const WeatherChart = { } const ctx = document.getElementById('combinedChart').getContext('2d'); - const chartConfig = { - type: 'line', - data: { - labels: allLabels, - datasets: datasets - }, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: 'index', intersect: false }, - layout: { padding: { top: 12, right: 16, bottom: 12, left: 16 } }, - plugins: { - legend: { - display: true, - position: 'top', - align: 'center', - labels: { - padding: 16 - } - } - }, - scales: { - 'y-temperature': { - type: 'linear', - display: true, - position: 'right', - title: { display: true, text: '温度 (°C)' } - }, + const totalLabels = allLabels.length; + const tickStep = Math.max(1, Math.ceil(totalLabels / 10)); // 约10个刻度 + const chartConfig = { + type: 'line', + data: { + labels: allLabels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + layout: { padding: { top: 12, right: 16, bottom: 12, left: 16 } }, + plugins: { + legend: { + display: true, + position: 'top', + align: 'center', + labels: { + padding: 16 + } + } + }, + scales: { + x: { + type: 'category', + ticks: { + autoSkip: false, + maxRotation: 0, + minRotation: 0, + callback: function(value, index) { + // 仅显示每 tickStep 个刻度,格式 MM-DD HH + if (index % tickStep !== 0) return ''; + const labels = this.chart?.data?.labels || []; + const raw = labels[index] || ''; + // 原始格式: YYYY-MM-DD HH:MM:SS + if (typeof raw === 'string' && raw.length >= 13) { + return raw.substring(5, 13); + } + return raw; + } + }, + grid: { + display: true, + drawOnChartArea: true + } + }, + 'y-temperature': { + type: 'linear', + display: true, + position: 'right', + title: { display: true, text: '温度 (°C)' } + }, 'y-humidity': { type: 'linear', display: true,