feat: 大屏幕的页面
This commit is contained in:
parent
abb45a13ec
commit
ca914355ba
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
10
config.yaml
10
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"
|
||||
|
||||
@ -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{
|
||||
|
||||
101
static/js/app.js
101
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 = `
|
||||
<strong>
|
||||
${station.location || '未知位置'} ·
|
||||
编号 ${decimalId} ·
|
||||
坐标 ${station.latitude ? station.latitude.toFixed(6) : '未知'}, ${station.longitude ? station.longitude.toFixed(6) : '未知'}
|
||||
</strong>
|
||||
`;
|
||||
} 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); }
|
||||
}
|
||||
|
||||
@ -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 = [];
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user