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 @@
+
-
-
-
+
-
+
在线设备: {{.OnlineDevices}} 个 |
-
总设备: 0 个
+
总设备: 0 个
-