From 29a3e9305be274286090beff335446cd4c33d8a0 Mon Sep 17 00:00:00 2001 From: yarnom Date: Sat, 23 Aug 2025 20:04:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/app.js | 257 +++++++++ static/js/utils.js | 49 ++ static/js/weather-app.js | 444 ++++++++++++++ static/js/weather-chart.js | 163 ++++++ static/js/weather-table.js | 103 ++++ templates/index.html | 1119 ++---------------------------------- 6 files changed, 1052 insertions(+), 1083 deletions(-) create mode 100644 static/js/app.js create mode 100644 static/js/utils.js create mode 100644 static/js/weather-app.js create mode 100644 static/js/weather-chart.js create mode 100644 static/js/weather-table.js diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..283ab26 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,257 @@ +// 应用主控制器 +const WeatherApp = { + cachedHistoryData: [], + cachedForecastData: [], + currentPage: 1, + itemsPerPage: 10, + filteredDevices: [], + + init() { + // 初始化日期 + WeatherUtils.initializeDateInputs(); + // 初始化地图 + WeatherMap.init(window.TIANDITU_KEY || ''); + WeatherMap.loadStations(); + // 定时刷新在线设备数 + setInterval(() => this.updateOnlineDevices(), 30000); + // 绑定 UI + this.bindUI(); + // 监听地图请求查询事件 + window.addEventListener('query-history-data', () => this.queryHistoryData()); + }, + + bindUI() { + const stationInput = document.getElementById('stationInput'); + if (stationInput) { + stationInput.addEventListener('input', function() { + this.value = this.value.toUpperCase().replace(/[^0-9A-F]/g, ''); + }); + stationInput.addEventListener('change', function() { + const value = this.value.trim(); + if (!value) return; + if (/^[0-9A-F]+$/i.test(value)) { + if (value.length <= 6) this.value = WeatherUtils.hexToDecimal(value); + } else { + const num = parseInt(value); + if (!isNaN(num)) this.value = num.toString(); + } + }); + } + + const showDeviceListBtn = document.getElementById('showDeviceList'); + const modal = document.getElementById('deviceModal'); + const closeBtn = document.querySelector('.close-modal'); + const prevPageBtn = document.getElementById('prevPage'); + const nextPageBtn = document.getElementById('nextPage'); + + if (showDeviceListBtn && modal) { + showDeviceListBtn.addEventListener('click', (e) => { + e.preventDefault(); + modal.style.display = 'block'; + this.updateDeviceList(1); + }); + } + if (prevPageBtn) { + prevPageBtn.addEventListener('click', () => { + if (this.currentPage > 1) this.updateDeviceList(this.currentPage - 1); + }); + } + if (nextPageBtn) { + nextPageBtn.addEventListener('click', () => { + const totalPages = Math.ceil(this.filteredDevices.length / this.itemsPerPage); + if (this.currentPage < totalPages) this.updateDeviceList(this.currentPage + 1); + }); + } + if (closeBtn && modal) { + closeBtn.addEventListener('click', () => modal.style.display = 'none'); + window.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; }); + } + const deviceListEl = document.getElementById('deviceList'); + if (deviceListEl && modal) { + deviceListEl.addEventListener('click', (e) => { + const deviceItem = e.target.closest('.device-item'); + if (!deviceItem) return; + const decimalId = deviceItem.getAttribute('data-decimal-id'); + const input = document.getElementById('stationInput'); + if (input) input.value = decimalId; + modal.style.display = 'none'; + this.queryHistoryData(); + }); + } + + const showPastForecast = document.getElementById('showPastForecast'); + if (showPastForecast) { + showPastForecast.addEventListener('change', () => { + WeatherTable.display(this.cachedHistoryData, this.cachedForecastData); + }); + } + + // 提供全局函数以兼容现有 HTML on* 绑定 + window.switchLayer = (type) => WeatherMap.switchLayer(type); + window.toggleMap = () => WeatherMap.toggleMap(); + window.queryHistoryData = () => this.queryHistoryData(); + }, + + // 更新设备列表 + updateDeviceList(page = 1) { + const deviceListContainer = document.getElementById('deviceList'); + if (!deviceListContainer) return; + deviceListContainer.innerHTML = ''; + + this.filteredDevices = (WeatherMap.stations || []) + .filter(station => station.device_type === 'WH65LP') + .sort((a, b) => { + const aOnline = WeatherUtils.isDeviceOnline(a.last_update); + const bOnline = WeatherUtils.isDeviceOnline(b.last_update); + if (aOnline === bOnline) return 0; + return aOnline ? -1 : 1; + }); + + const totalPages = Math.ceil(this.filteredDevices.length / this.itemsPerPage) || 1; + this.currentPage = Math.min(Math.max(1, page), totalPages); + + const currentPageEl = document.getElementById('currentPage'); + const totalPagesEl = document.getElementById('totalPages'); + const prevBtn = document.getElementById('prevPage'); + const nextBtn = document.getElementById('nextPage'); + if (currentPageEl) currentPageEl.textContent = this.currentPage; + if (totalPagesEl) totalPagesEl.textContent = totalPages; + if (prevBtn) prevBtn.disabled = this.currentPage <= 1; + if (nextBtn) nextBtn.disabled = this.currentPage >= totalPages; + + const startIndex = (this.currentPage - 1) * this.itemsPerPage; + const endIndex = startIndex + this.itemsPerPage; + const currentDevices = this.filteredDevices.slice(startIndex, endIndex); + + currentDevices.forEach(device => { + const isOnline = WeatherUtils.isDeviceOnline(device.last_update); + const deviceItem = document.createElement('div'); + deviceItem.className = 'device-item'; + deviceItem.setAttribute('data-decimal-id', device.decimal_id); + deviceItem.innerHTML = ` +
+ ${device.decimal_id} | ${device.name} | ${device.location || '未知位置'} +
+ ${isOnline ? '在线' : '离线'} + `; + deviceListContainer.appendChild(deviceItem); + }); + + if (this.filteredDevices.length === 0) { + deviceListContainer.innerHTML = '
暂无WH65LP设备
'; + } + }, + + async updateOnlineDevices() { + try { + const response = await fetch('/api/system/status'); + const data = await response.json(); + const onlineEl = document.getElementById('onlineDevices'); + if (onlineEl) onlineEl.textContent = data.online_devices; + } catch (error) { + console.error('更新在线设备数量失败:', error); + } + }, + + async queryHistoryData() { + const decimalId = (document.getElementById('stationInput')?.value || '').trim(); + if (!decimalId) { + alert('请输入站点编号'); + return; + } + if (!/^\d+$/.test(decimalId)) { + alert('请输入有效的十进制编号'); + 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; + if (!startTime || !endTime) { + alert('请选择开始和结束时间'); + return; + } + + try { + const historyParams = new URLSearchParams({ + decimal_id: decimalId, + start_time: startTime.replace('T', ' ') + ':00', + end_time: endTime.replace('T', ' ') + ':00', + interval: interval + }); + const historyResponse = await fetch(`/api/data?${historyParams}`); + if (!historyResponse.ok) throw new Error('查询历史数据失败'); + const responseData = await historyResponse.json(); + const historyData = Array.isArray(responseData) ? responseData : []; + + let forecastData = []; + if (forecastProvider && interval === '1hour') { + try { + const hexID = WeatherUtils.decimalToHex(decimalId); + const stationID = `RS485-${hexID}`; + const 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 (e) { + console.warn('查询预报数据失败:', e); + } + } + + this.cachedHistoryData = historyData; + this.cachedForecastData = forecastData; + + if (historyData.length === 0 && forecastData.length === 0) { + alert('该时间段内无数据'); + return; + } + + 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}`; + } + } + + if (!WeatherMap.isMapCollapsed) WeatherMap.toggleMap(); + + WeatherChart.display(historyData, forecastData); + WeatherTable.display(historyData, forecastData); + + const chartContainer = document.getElementById('chartContainer'); + const tableContainer = document.getElementById('tableContainer'); + if (chartContainer) chartContainer.classList.add('show'); + if (tableContainer) tableContainer.classList.add('show'); + setTimeout(() => { + chartContainer?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 300); + } catch (error) { + console.error('查询数据失败:', error); + alert('查询数据失败: ' + error.message); + } + } +}; + +window.WeatherApp = WeatherApp; + +document.addEventListener('DOMContentLoaded', () => { + WeatherApp.init(); +}); \ No newline at end of file diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..456294c --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,49 @@ +// 工具函数 +const WeatherUtils = { + // 格式化日期时间 + formatDatetimeLocal(date) { + const offset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - offset * 60 * 1000); + return localDate.toISOString().slice(0, 16); + }, + + // 十六进制转十进制 + hexToDecimal(hex) { + return parseInt(hex, 16).toString(); + }, + + // 十进制转十六进制(保持6位,不足补0) + decimalToHex(decimal) { + const hex = parseInt(decimal).toString(16).toUpperCase(); + return '0'.repeat(Math.max(0, 6 - hex.length)) + hex; + }, + + // 检查是否为十六进制字符串 + isHexString(str) { + return /^[0-9A-F]+$/i.test(str); + }, + + // 格式化数字(保留2位小数) + formatNumber(value, decimals = 2) { + if (value === null || value === undefined) return '-'; + return Number(value).toFixed(decimals); + }, + + // 检查设备是否在线 + isDeviceOnline(lastUpdate) { + return new Date(lastUpdate) > new Date(Date.now() - 5 * 60 * 1000); + }, + + // 初始化日期输入 + initializeDateInputs() { + const now = new Date(); + const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 过去24小时 + const endDate = new Date(now.getTime() + 3 * 60 * 60 * 1000); // 未来3小时 + + document.getElementById('startDate').value = this.formatDatetimeLocal(startDate); + document.getElementById('endDate').value = this.formatDatetimeLocal(endDate); + } +}; + +// 导出工具对象 +window.WeatherUtils = WeatherUtils; \ No newline at end of file diff --git a/static/js/weather-app.js b/static/js/weather-app.js new file mode 100644 index 0000000..549240c --- /dev/null +++ b/static/js/weather-app.js @@ -0,0 +1,444 @@ +// 地图相关功能 +const WeatherMap = { + map: null, + stations: [], + stationLayer: null, + clusterLayer: null, + clusterSource: null, + singleStationLayer: null, + combinedChart: null, + CLUSTER_THRESHOLD: 10, + isMapCollapsed: false, + + // 初始化地图 + init(tiandituKey) { + this.initializeLayers(); + this.initializeMap(tiandituKey); + this.setupEventListeners(); + }, + + // 初始化图层 + initializeLayers() { + this.stationLayer = new ol.layer.Vector({ + source: new ol.source.Vector() + }); + + this.clusterSource = new ol.source.Cluster({ + distance: 60, + minDistance: 20, + source: this.stationLayer.getSource(), + geometryFunction: function(feature) { + return feature.getGeometry(); + } + }); + + this.clusterLayer = new ol.layer.Vector({ + source: this.clusterSource, + style: (feature) => this.createClusterStyle(feature) + }); + + this.singleStationLayer = new ol.layer.Vector({ + source: this.stationLayer.getSource(), + style: (feature) => this.createStationStyle(feature), + visible: false + }); + }, + + // 初始化地图 + initializeMap(tiandituKey) { + const layers = this.createMapLayers(tiandituKey); + this.layers = layers; + + this.map = new ol.Map({ + target: 'map', + layers: [ + layers.satellite, + layers.vector, + layers.terrain, + layers.hybrid, + this.clusterLayer, + this.singleStationLayer + ], + view: new ol.View({ + center: ol.proj.fromLonLat([108, 35]), + zoom: 5, + minZoom: 3, + maxZoom: 18 + }) + }); + + // 初始化时设置图层状态 + const initialZoom = this.map.getView().getZoom(); + this.updateClusterDistance(initialZoom); + this.updateLayerVisibility(initialZoom); + }, + + // 创建地图图层 + createMapLayers(tiandituKey) { + return { + satellite: new ol.layer.Group({ + layers: [ + new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: `https://t{0-7}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` + }) + }), + new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: `https://t{0-7}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` + }) + }) + ] + }), + vector: new ol.layer.Group({ + layers: [ + new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: `https://t{0-7}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` + }) + }), + new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: `https://t{0-7}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` + }) + }) + ], + visible: false + }), + terrain: new ol.layer.Group({ + layers: [ + new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: `https://t{0-7}.tianditu.gov.cn/ter_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` + }) + }), + new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: `https://t{0-7}.tianditu.gov.cn/cta_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cta&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` + }) + }) + ], + visible: false + }), + hybrid: new ol.layer.Group({ + layers: [ + new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: `https://t{0-7}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` + }) + }), + new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: `https://t{0-7}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tiandituKey}` + }) + }) + ], + visible: false + }) + }; + }, + + // 设置事件监听 + setupEventListeners() { + // 监听缩放事件 + this.map.getView().on('change:resolution', () => { + const zoom = this.map.getView().getZoom(); + this.updateClusterDistance(zoom); + this.updateLayerVisibility(zoom); + }); + + // 添加点击事件 + this.map.on('click', (event) => this.handleMapClick(event)); + + // 添加鼠标移动事件 + this.map.on('pointermove', (event) => { + const pixel = this.map.getEventPixel(event.originalEvent); + const hit = this.map.hasFeatureAtPixel(pixel); + this.map.getTargetElement().style.cursor = hit ? 'pointer' : ''; + }); + }, + + // 处理地图点击 + handleMapClick(event) { + const feature = this.map.forEachFeatureAtPixel(event.pixel, feature => feature); + + if (!feature) return; + + const features = feature.get('features'); + if (features && features.length > 1) { + this.handleClusterClick(features); + } else { + this.handleStationClick(features ? features[0] : feature); + } + }, + + // 处理集群点击 + handleClusterClick(features) { + const extent = ol.extent.createEmpty(); + features.forEach(feature => { + ol.extent.extend(extent, feature.getGeometry().getExtent()); + }); + + const zoom = this.map.getView().getZoom(); + const targetZoom = Math.min( + features.length <= 5 ? this.CLUSTER_THRESHOLD : zoom + 2, + this.CLUSTER_THRESHOLD + ); + + this.map.getView().fit(extent, { + padding: [100, 100, 100, 100], + duration: 800, + maxZoom: targetZoom + }); + }, + + // 处理站点点击 + handleStationClick(feature) { + const decimalId = feature.get('decimalId'); + if (!decimalId) return; + + document.getElementById('stationInput').value = decimalId; + + const center = feature.getGeometry().getCoordinates(); + const currentZoom = this.map.getView().getZoom(); + + if (currentZoom < this.CLUSTER_THRESHOLD) { + this.map.getView().animate({ + center: center, + zoom: this.CLUSTER_THRESHOLD, + duration: 500 + }); + } + + if (document.getElementById('interval').value === '1hour') { + // 触发查询事件 + window.dispatchEvent(new CustomEvent('query-history-data')); + } + }, + + // 更新集群距离 + updateClusterDistance(zoom) { + let distance; + if (zoom < 5) distance = 120; + else if (zoom < 7) distance = 90; + else if (zoom < 9) distance = 60; + else if (zoom < this.CLUSTER_THRESHOLD) distance = 40; + else distance = 0; + + this.clusterSource.setDistance(distance); + this.clusterSource.refresh(); + + setTimeout(() => { + this.clusterLayer.changed(); + this.singleStationLayer.changed(); + }, 100); + }, + + // 更新图层可见性 + updateLayerVisibility(zoom) { + if (zoom >= this.CLUSTER_THRESHOLD) { + this.clusterLayer.setVisible(false); + this.singleStationLayer.setVisible(true); + } else { + this.clusterLayer.setVisible(true); + this.singleStationLayer.setVisible(false); + } + }, + + // 创建站点样式 + createStationStyle(feature) { + const isOnline = new Date(feature.get('lastUpdate')) > new Date(Date.now() - 5*60*1000); + const zoom = this.map ? this.map.getView().getZoom() : 10; + let labelText = ''; + + if (zoom >= this.CLUSTER_THRESHOLD - 2) { + labelText = `${feature.get('decimalId') || '未知'} | ${feature.get('name') || '未知'} | ${feature.get('location') || '未知'}`; + } + + return new ol.style.Style({ + image: this.getMarkerIconStyle(isOnline).getImage(), + text: new ol.style.Text({ + text: labelText, + font: '11px Arial', + offsetY: -24, + textAlign: 'center', + textBaseline: 'bottom', + fill: new ol.style.Fill({ color: '#666' }), + stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) + }) + }); + }, + + // 创建集群样式 + createClusterStyle(feature) { + const features = feature.get('features'); + const size = features.length; + const zoom = this.map.getView().getZoom(); + + if (zoom < this.CLUSTER_THRESHOLD) { + if (size > 1) { + const radius = Math.min(16 + size * 0.8, 32); + const fontSize = Math.min(11 + size/12, 16); + + return new ol.style.Style({ + image: new ol.style.Circle({ + radius: radius, + fill: new ol.style.Fill({ color: 'rgba(0, 123, 255, 0.8)' }), + stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) + }), + text: new ol.style.Text({ + text: String(size), + fill: new ol.style.Fill({ color: '#fff' }), + font: `bold ${fontSize}px Arial`, + offsetY: 1 + }) + }); + } + return new ol.style.Style({ + image: new ol.style.Circle({ + radius: 6, + fill: new ol.style.Fill({ + color: new Date(features[0].get('lastUpdate')) > + new Date(Date.now() - 5*60*1000) ? '#007bff' : '#6c757d' + }), + stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) + }) + }); + } + + if (size === 1) { + return this.createStationStyle(features[0]); + } + + const radius = Math.min(16 + size * 0.8, 32); + const fontSize = Math.min(11 + size/12, 16); + + return new ol.style.Style({ + image: new ol.style.Circle({ + radius: radius, + fill: new ol.style.Fill({ color: 'rgba(0, 123, 255, 0.8)' }), + stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) + }), + text: new ol.style.Text({ + text: String(size), + fill: new ol.style.Fill({ color: '#fff' }), + font: `bold ${fontSize}px Arial`, + offsetY: 1 + }) + }); + }, + + // 获取标记图标样式 + getMarkerIconStyle(isOnline) { + const key = isOnline ? 'online' : 'offline'; + if (this.markerStyleCache?.[key]) return this.markerStyleCache[key]; + + const iconPath = isOnline ? '/static/images/marker-online.svg' : '/static/images/marker-offline.svg'; + const style = new ol.style.Style({ + image: new ol.style.Icon({ + src: iconPath, + anchor: [0.5, 1], + anchorXUnits: 'fraction', + anchorYUnits: 'fraction', + scale: 0.9 + }) + }); + + if (!this.markerStyleCache) this.markerStyleCache = {}; + this.markerStyleCache[key] = style; + return style; + }, + + // 切换地图图层 + switchLayer(layerType) { + Object.keys(this.layers).forEach(key => { + this.layers[key].setVisible(key === layerType); + }); + }, + + // 切换地图折叠状态 + toggleMap() { + this.isMapCollapsed = !this.isMapCollapsed; + const mapContainer = document.getElementById('mapContainer'); + const toggleBtn = document.getElementById('toggleMapBtn'); + + if (this.isMapCollapsed) { + mapContainer.classList.add('collapsed'); + toggleBtn.textContent = '展开地图'; + } else { + mapContainer.classList.remove('collapsed'); + toggleBtn.textContent = '折叠地图'; + } + + setTimeout(() => { + this.map.updateSize(); + }, 300); + }, + + // 加载站点数据 + async loadStations() { + try { + const response = await fetch('/api/stations'); + this.stations = await response.json(); + + // 更新WH65LP设备数量 + const wh65lpDevices = this.stations.filter(station => station.device_type === 'WH65LP'); + document.getElementById('wh65lpCount').textContent = wh65lpDevices.length; + + this.displayStationsOnMap(); + } catch (error) { + console.error('加载站点失败:', error); + } + }, + + // 在地图上显示站点 + displayStationsOnMap() { + const source = this.stationLayer.getSource(); + source.clear(); + + const now = Date.now(); + const fiveMinutesAgo = now - 5 * 60 * 1000; + let onlineCount = 0; + let offlineCount = 0; + + this.stations.forEach(station => { + if (station.latitude && station.longitude) { + const isOnline = new Date(station.last_update) > new Date(fiveMinutesAgo); + if (isOnline) onlineCount++; else offlineCount++; + + const feature = new ol.Feature({ + geometry: new ol.geom.Point(ol.proj.fromLonLat([station.longitude, station.latitude])), + stationId: station.station_id, + decimalId: station.decimal_id, + name: station.name, + location: station.location, + lastUpdate: station.last_update, + isOnline: isOnline + }); + + source.addFeature(feature); + } + }); + + console.log(`已加载 ${this.stations.length} 个站点,在线: ${onlineCount},离线: ${offlineCount}`); + + if (source.getFeatures().length > 0) { + if (source.getFeatures().length === 1) { + const feature = source.getFeatures()[0]; + this.map.getView().setCenter(feature.getGeometry().getCoordinates()); + this.map.getView().setZoom(12); + } else { + const extent = source.getExtent(); + this.map.getView().fit(extent, { + padding: [50, 50, 50, 50], + maxZoom: 10 + }); + } + } + + this.updateClusterDistance(this.map.getView().getZoom()); + } +}; + +// 导出地图对象 +window.WeatherMap = WeatherMap; \ No newline at end of file diff --git a/static/js/weather-chart.js b/static/js/weather-chart.js new file mode 100644 index 0000000..3201943 --- /dev/null +++ b/static/js/weather-chart.js @@ -0,0 +1,163 @@ +// 图表相关功能 +const WeatherChart = { + chart: null, + + // 显示图表 + display(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 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 (this.chart) this.chart.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'); + this.chart = new Chart(ctx, { + type: 'line', + data: { + labels: allLabels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + scales: { + 'y-temperature': { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: '温度 (°C)' + } + }, + 'y-humidity': { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: '湿度 (%)' + }, + grid: { + drawOnChartArea: false + }, + min: 0, + max: 100 + }, + 'y-rainfall': { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: '雨量 (mm)' + }, + grid: { + drawOnChartArea: false + }, + beginAtZero: true + } + } + } + }); + } +}; + +// 导出图表对象 +window.WeatherChart = WeatherChart; \ No newline at end of file diff --git a/static/js/weather-table.js b/static/js/weather-table.js new file mode 100644 index 0000000..1332b5d --- /dev/null +++ b/static/js/weather-table.js @@ -0,0 +1,103 @@ +// 表格渲染功能 +const WeatherTable = { + display(historyData = [], forecastData = []) { + const tbody = document.getElementById('tableBody'); + if (!tbody) return; + tbody.innerHTML = ''; + + historyData = Array.isArray(historyData) ? historyData : []; + forecastData = Array.isArray(forecastData) ? forecastData : []; + + const nowTs = Date.now(); + const future3hTs = nowTs + 3 * 60 * 60 * 1000; + const showPastForecastEl = document.getElementById('showPastForecast'); + const shouldShowPast = !!(showPastForecastEl && showPastForecastEl.checked); + + const displayedForecast = forecastData.filter(item => { + const t = new Date(item.date_time).getTime(); + const isFuture3h = t > nowTs && t <= future3hTs; + const isPast = t <= nowTs; + return isFuture3h || (shouldShowPast && isPast); + }); + const hasForecast = displayedForecast.length > 0; + + const forecastToggleContainer = document.getElementById('forecastToggleContainer'); + if (forecastToggleContainer) { + forecastToggleContainer.style.display = forecastData.length > 0 ? 'block' : 'none'; + } + + const thead = document.getElementById('tableHeader'); + if (thead) { + thead.innerHTML = ''; + const fixedHeaders = ['时间', '温度 (°C)', '湿度 (%)', '气压 (hPa)', '风速 (m/s)', '风向 (°)', '雨量 (mm)']; + fixedHeaders.forEach(text => { + const th = document.createElement('th'); + th.textContent = text; + th.className = 'bg-gray-50 font-semibold'; + thead.appendChild(th); + }); + if (hasForecast) { + const th = document.createElement('th'); + th.textContent = '降水概率 (%)'; + th.className = 'bg-gray-50 font-semibold'; + thead.appendChild(th); + } + const remainingHeaders = ['光照 (lux)', '紫外线']; + remainingHeaders.forEach(text => { + const th = document.createElement('th'); + th.textContent = text; + th.className = 'bg-gray-50 font-semibold'; + thead.appendChild(th); + }); + } + + const allData = []; + historyData.forEach(item => { + allData.push({ ...item, source: '实测' }); + }); + displayedForecast.forEach(item => { + allData.push({ + ...item, + source: '预报', + 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)); + + const fmt2 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(2)); + const fmt3 = v => (v === null || v === undefined ? '-' : Number(v).toFixed(3)); + + allData.forEach(item => { + const row = document.createElement('tr'); + if (item.source === '预报') { + row.style.backgroundColor = 'rgba(255, 165, 0, 0.08)'; + } + + const columns = [ + `${item.date_time}${hasForecast ? ` [${item.source}]` : ''}`, + `${fmt2(item.temperature)}`, + `${fmt2(item.humidity)}`, + `${fmt2(item.pressure)}`, + `${fmt2(item.wind_speed)}`, + `${fmt2(item.wind_direction)}`, + `${fmt3(item.rainfall)}` + ]; + + if (hasForecast) { + columns.push(`${item.source === '预报' && item.precip_prob !== null && item.precip_prob !== undefined ? item.precip_prob : '-'}`); + } + + columns.push( + `${(item.light !== null && item.light !== undefined) ? Number(item.light).toFixed(2) : '-'}`, + `${(item.uv !== null && item.uv !== undefined) ? Number(item.uv).toFixed(2) : '-'}` + ); + + row.innerHTML = columns.join(''); + tbody.appendChild(row); + }); + } +}; + +window.WeatherTable = WeatherTable; \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 67f158e..35d4bf2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,6 +8,7 @@ + - -
-

{{.Title}}

+ +
+

{{.Title}}

@@ -435,24 +436,24 @@
-
+
-
+
在线设备: {{.OnlineDevices}} 个 | - 总设备: 0 + 总设备: 0
-
-
-
- - +
+
+
+ +
- - @@ -461,10 +462,10 @@
-
-
- - @@ -472,8 +473,8 @@
- - @@ -481,25 +482,25 @@
- - + +
- - + +
- +
-
+
- +
@@ -514,11 +515,11 @@
- +
@@ -540,1061 +541,13 @@ + + + + + + \ No newline at end of file
时间