2025-08-08 10:07:50 +08:00

871 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- OpenLayers CSS and JS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@7.5.2/ol.css">
<script src="https://cdn.jsdelivr.net/npm/ol@7.5.2/dist/ol.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
.header {
padding: 10px;
text-align: center;
border-bottom: 1px solid #ddd;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 15px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
}
.control-group {
display: flex;
align-items: center;
gap: 5px;
}
.station-input-group {
display: flex;
align-items: center;
gap: 5px;
}
#stationInput {
width: 120px;
padding: 5px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
}
select, input, button {
padding: 5px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #0056b3;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.map-container {
height: 60vh;
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 4px;
position: relative;
transition: height 0.3s ease;
overflow: hidden;
}
.map-container.collapsed {
height: 100px;
}
#map {
width: 100%;
height: 100%;
}
.map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 5px;
}
.map-control-btn {
padding: 5px 10px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.map-control-btn:hover {
background-color: white;
}
.map-control-btn.active {
background-color: #007bff;
color: white;
}
.chart-container {
margin-bottom: 20px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
background-color: #fff;
display: none;
}
.chart-container.show {
display: block;
animation: slideDown 0.3s ease;
}
.chart-wrapper {
height: 500px;
margin-bottom: 30px;
}
.table-container {
overflow-x: auto;
margin-top: 20px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
display: none;
}
.table-container.show {
display: block;
animation: slideDown 0.3s ease;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border: 1px solid #ddd;
padding: 12px 8px;
text-align: left;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
tr:hover {
background-color: #f5f5f5;
}
.system-info {
background-color: #e9ecef;
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
font-size: 14px;
}
.error {
color: #dc3545;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 10px;
margin: 10px 0;
}
.station-marker {
width: 24px;
height: 24px;
background-color: #007bff;
border: 2px solid white;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.station-marker.offline {
background-color: #6c757d;
}
.station-label {
background-color: rgba(255, 255, 255, 0.8);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #007bff;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.controls {
flex-direction: column;
}
.control-group {
width: 100%;
}
select, input {
width: 100%;
}
.map-container {
height: 50vh;
}
}
</style>
</head>
<body>
<div class="header">
<h1>{{.Title}}</h1>
</div>
<div class="container">
<!-- 系统信息 -->
<div class="system-info">
<strong>系统信息:</strong> 服务器时间: {{.ServerTime}} | 在线设备: <span id="onlineDevices">{{.OnlineDevices}}</span>
</div>
<!-- 控制面板 -->
<div class="controls">
<div class="station-input-group">
<label for="stationInput">站点编号:</label>
<input type="text" id="stationInput" placeholder="输入十进制编号10738" style="width: 200px;">
</div>
<div class="control-group">
<label for="mapType">地图类型:</label>
<select id="mapType" onchange="switchLayer(this.value)">
<option value="vector">矢量图</option>
<option value="terrain">地形图</option>
<option value="hybrid">混合地形图</option>
</select>
</div>
<div class="control-group">
<label for="interval">数据粒度:</label>
<select id="interval">
<option value="10min">10分钟</option>
<option value="30min">30分钟</option>
<option value="1hour" selected>1小时</option>
</select>
</div>
<div class="control-group">
<label for="startDate">开始时间:</label>
<input type="datetime-local" id="startDate">
</div>
<div class="control-group">
<label for="endDate">结束时间:</label>
<input type="datetime-local" id="endDate">
</div>
<div class="control-group">
<button onclick="queryHistoryData()" id="queryBtn">查看历史数据</button>
</div>
</div>
<!-- 地图容器 -->
<div class="map-container" id="mapContainer">
<div id="map"></div>
</div>
<!-- 图表容器 -->
<div class="chart-container" id="chartContainer">
<div class="chart-wrapper">
<canvas id="combinedChart"></canvas>
</div>
</div>
<!-- 数据表格 -->
<div class="table-container" id="tableContainer">
<table>
<thead>
<tr>
<th>时间</th>
<th>温度 (°C)</th>
<th>湿度 (%)</th>
<th>气压 (hPa)</th>
<th>风速 (m/s)</th>
<th>风向 (°)</th>
<th>雨量 (mm)</th>
<th>光照 (lux)</th>
<th>紫外线</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- 数据行将动态填充 -->
</tbody>
</table>
</div>
</div>
<script>
const TIANDITU_KEY = '{{.TiandituKey}}';
let map;
let stations = [];
let stationLayer;
let clusterLayer;
let clusterSource;
let combinedChart = null;
const CLUSTER_THRESHOLD = 11; // 缩放级别阈值,小于此值时启用集群
// 十六进制转十进制
function hexToDecimal(hex) {
return parseInt(hex, 16).toString();
}
// 十进制转十六进制保持6位不足补0
function decimalToHex(decimal) {
const hex = parseInt(decimal).toString(16).toUpperCase();
return '0'.repeat(Math.max(0, 6 - hex.length)) + hex;
}
// 地图图层
const layers = {
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=' + TIANDITU_KEY
})
}),
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=' + TIANDITU_KEY
})
})
]
}),
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=' + TIANDITU_KEY
})
}),
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=' + TIANDITU_KEY
})
})
],
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=' + TIANDITU_KEY
})
}),
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=' + TIANDITU_KEY
})
})
],
visible: false
})
};
// 初始化页面
document.addEventListener('DOMContentLoaded', function() {
initializeDateInputs();
initializeMap();
loadStations();
// 每30秒刷新在线设备数量
setInterval(updateOnlineDevices, 30000);
// 添加输入框事件监听
const stationInput = document.getElementById('stationInput');
stationInput.addEventListener('input', function(e) {
// 移除非数字字符
this.value = this.value.replace(/[^0-9]/g, '');
});
});
// 初始化日期输入
function initializeDateInputs() {
const now = new Date();
const endDate = new Date(now);
const startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24小时前
document.getElementById('startDate').value = formatDatetimeLocal(startDate);
document.getElementById('endDate').value = formatDatetimeLocal(endDate);
}
function formatDatetimeLocal(date) {
const offset = date.getTimezoneOffset();
const localDate = new Date(date.getTime() - offset * 60 * 1000);
return localDate.toISOString().slice(0, 16);
}
// 初始化地图
function initializeMap() {
// 创建站点图层
stationLayer = new ol.layer.Vector({
source: new ol.source.Vector()
});
// 创建集群源和图层
clusterSource = new ol.source.Cluster({
distance: 40,
source: stationLayer.getSource()
});
clusterLayer = new ol.layer.Vector({
source: clusterSource,
style: function(feature) {
const size = feature.get('features').length;
if (size === 1) {
// 单个站点样式
return createStationStyle(feature.get('features')[0]);
}
// 集群样式
return new ol.style.Style({
image: new ol.style.Circle({
radius: 15,
fill: new ol.style.Fill({
color: '#007bff'
}),
stroke: new ol.style.Stroke({
color: '#fff',
width: 2
})
}),
text: new ol.style.Text({
text: size.toString(),
fill: new ol.style.Fill({
color: '#fff'
}),
font: '12px Arial'
})
});
}
});
// 创建地图
map = new ol.Map({
target: 'map',
layers: [
layers.vector,
layers.terrain,
layers.hybrid,
clusterLayer
],
view: new ol.View({
center: ol.proj.fromLonLat([108, 35]), // 中国中心
zoom: 5,
minZoom: 3,
maxZoom: 18
})
});
// 监听缩放事件
map.getView().on('change:resolution', function() {
const zoom = map.getView().getZoom();
updateClusterDistance(zoom);
});
// 添加点击事件
map.on('click', function(event) {
const feature = map.forEachFeatureAtPixel(event.pixel, function(feature) {
return feature;
});
if (feature) {
const features = feature.get('features');
if (features && features.length > 1) {
// 点击集群,放大地图
const extent = ol.extent.createEmpty();
features.forEach(function(feature) {
ol.extent.extend(extent, feature.getGeometry().getExtent());
});
map.getView().fit(extent, {
padding: [50, 50, 50, 50],
duration: 1000,
maxZoom: CLUSTER_THRESHOLD
});
} else {
// 单个站点获取十进制ID
const singleFeature = features ? features[0] : feature;
const decimalId = singleFeature.get('decimalId');
if (decimalId) {
document.getElementById('stationInput').value = decimalId;
// 如果是1小时粒度自动查询
if (document.getElementById('interval').value === '1hour') {
queryHistoryData();
}
}
}
}
});
// 添加鼠标悬停效果
map.on('pointermove', function(event) {
const pixel = map.getEventPixel(event.originalEvent);
const hit = map.hasFeatureAtPixel(pixel);
map.getTargetElement().style.cursor = hit ? 'pointer' : '';
});
}
// 切换地图图层
function switchLayer(layerType) {
Object.keys(layers).forEach(key => {
layers[key].setVisible(key === layerType);
});
document.querySelectorAll('.map-control-btn').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById(layerType + 'Btn').classList.add('active');
}
// 加载站点数据
async function loadStations() {
try {
const response = await fetch('/api/stations');
stations = await response.json();
displayStationsOnMap();
} catch (error) {
console.error('加载站点失败:', error);
}
}
// 更新集群距离
function updateClusterDistance(zoom) {
const distance = zoom < CLUSTER_THRESHOLD ? 40 : 0;
clusterSource.setDistance(distance);
}
// 创建站点样式
function createStationStyle(feature) {
const isOnline = new Date(feature.get('lastUpdate')) > new Date(Date.now() - 5*60*1000);
const labelText = [
`十进制ID: ${feature.get('decimalId') || '未知'}`,
`十六进制ID: ${feature.get('stationId').split('-')[1] || '未知'}`,
`名称: ${feature.get('name') || '未知'}`,
`位置: ${feature.get('location') || '未知'}`
].join('\n');
return new ol.style.Style({
image: new ol.style.Circle({
radius: 6,
fill: new ol.style.Fill({
color: isOnline ? '#007bff' : '#6c757d'
}),
stroke: new ol.style.Stroke({
color: '#fff',
width: 2
})
}),
text: new ol.style.Text({
text: labelText,
font: '12px Arial',
offsetY: -30,
textAlign: 'left',
textBaseline: 'bottom',
fill: new ol.style.Fill({
color: '#333'
}),
stroke: new ol.style.Stroke({
color: '#fff',
width: 3
}),
padding: [5, 5, 5, 5],
backgroundFill: new ol.style.Fill({
color: 'rgba(255, 255, 255, 0.8)'
})
})
});
}
// 在地图上显示站点
function displayStationsOnMap() {
const source = stationLayer.getSource();
source.clear();
stations.forEach(station => {
if (station.latitude && station.longitude) {
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
});
source.addFeature(feature);
}
});
// 自动缩放到所有站点
if (source.getFeatures().length > 0) {
const extent = source.getExtent();
map.getView().fit(extent, {
padding: [50, 50, 50, 50],
duration: 1000
});
}
}
// 切换地图显示
function toggleMap() {
const mapContainer = document.getElementById('mapContainer');
const toggleBtn = document.getElementById('toggleMapBtn');
isMapCollapsed = !isMapCollapsed;
if (isMapCollapsed) {
mapContainer.classList.add('collapsed');
toggleBtn.textContent = '展开地图';
} else {
mapContainer.classList.remove('collapsed');
toggleBtn.textContent = '折叠地图';
}
// 重新调整地图大小
setTimeout(() => {
map.updateSize();
}, 300);
}
// 更新在线设备数量
async function updateOnlineDevices() {
try {
const response = await fetch('/api/system/status');
const data = await response.json();
document.getElementById('onlineDevices').textContent = data.online_devices;
} catch (error) {
console.error('更新在线设备数量失败:', error);
}
}
// 查询历史数据
async function 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;
if (!startTime || !endTime) {
alert('请选择开始和结束时间');
return;
}
try {
const params = new URLSearchParams({
decimal_id: decimalId,
start_time: startTime.replace('T', ' ') + ':00',
end_time: endTime.replace('T', ' ') + ':00',
interval: interval
});
const response = await fetch(`/api/data?${params}`);
if (!response.ok) {
throw new Error('查询失败');
}
const data = await response.json();
if (data.length === 0) {
alert('该时间段内无数据');
return;
}
displayChart(data);
displayTable(data);
// 显示图表和表格
document.getElementById('chartContainer').classList.add('show');
document.getElementById('tableContainer').classList.add('show');
} catch (error) {
console.error('查询历史数据失败:', error);
alert('查询历史数据失败: ' + error.message);
}
}
// 显示图表
function displayChart(data) {
const labels = data.map(item => item.date_time);
const temperatures = data.map(item => item.temperature);
const humidities = data.map(item => item.humidity);
const rainfalls = data.map(item => item.rainfall);
// 销毁旧图表
if (combinedChart) combinedChart.destroy();
// 创建组合图表
const ctx = document.getElementById('combinedChart').getContext('2d');
combinedChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '温度 (°C)',
data: temperatures,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
yAxisID: 'y-temperature',
tension: 0.4
},
{
label: '湿度 (%)',
data: humidities,
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
yAxisID: 'y-humidity',
tension: 0.4,
hidden: true // 默认隐藏湿度数据
},
{
label: '雨量 (mm)',
data: rainfalls,
type: 'bar',
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgb(54, 162, 235)',
yAxisID: 'y-rainfall'
}
]
},
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
}
}
}
});
}
// 显示数据表格
function displayTable(data) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
data.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${item.date_time}</td>
<td>${item.temperature.toFixed(2)}</td>
<td>${item.humidity.toFixed(2)}</td>
<td>${item.pressure.toFixed(2)}</td>
<td>${item.wind_speed.toFixed(2)}</td>
<td>${item.wind_direction.toFixed(2)}</td>
<td>${item.rainfall.toFixed(3)}</td>
<td>${item.light.toFixed(2)}</td>
<td>${item.uv.toFixed(2)}</td>
`;
tbody.appendChild(row);
});
}
</script>
</body>
</html>