fix: 优化前端体验

This commit is contained in:
yarnom 2025-11-05 18:25:34 +08:00
parent 34b7bf3ff2
commit 4a73a41a6c
6 changed files with 68 additions and 37 deletions

View File

@ -21,7 +21,7 @@
<!-- 第一行:站点编号 / 地图类型 / 预报源 / 图例 --> <!-- 第一行:站点编号 / 地图类型 / 预报源 / 图例 -->
<div class="flex flex-wrap items-center gap-3 mb-3"> <div class="flex flex-wrap items-center gap-3 mb-3">
<label class="text-sm text-gray-600">站点编号</label> <label class="text-sm text-gray-600">站点编号</label>
<input class="px-2 py-1 border rounded w-32 font-mono text-sm" [(ngModel)]="decimalId" placeholder="十进制ID" /> <input class="px-2 py-1 border rounded w-32 font-mono text-sm" [(ngModel)]="decimalId" placeholder="十六进制(如 29CA" />
<label class="text-sm text-gray-600">地图类型</label> <label class="text-sm text-gray-600">地图类型</label>
<select class="px-2 py-1 border rounded text-sm" [(ngModel)]="mapType" (change)="switchLayer(mapType)"> <select class="px-2 py-1 border rounded text-sm" [(ngModel)]="mapType" (change)="switchLayer(mapType)">

View File

@ -30,8 +30,8 @@ export class ApiService {
try { const r = await fetch('/api/stations'); return await r.json(); } catch { return []; } try { const r = await fetch('/api/stations'); return await r.json(); } catch { return []; }
} }
async getHistory(decimalId: string, from: string, to: string, interval: string): Promise<WeatherPoint[]> { async getHistory(hexId: string, from: string, to: string, interval: string): Promise<WeatherPoint[]> {
const params = new URLSearchParams({ decimal_id: decimalId, start_time: from, end_time: to, interval }); const params = new URLSearchParams({ hex_id: hexId, start_time: from, end_time: to, interval });
const r = await fetch(`/api/data?${params.toString()}`); const r = await fetch(`/api/data?${params.toString()}`);
if (!r.ok) return []; if (!r.ok) return [];
return await r.json(); return await r.json();
@ -44,4 +44,3 @@ export class ApiService {
return await r.json(); return await r.json();
} }
} }

View File

@ -7,11 +7,10 @@
<div class="device-list" style="flex:1;overflow:auto;"> <div class="device-list" style="flex:1;overflow:auto;">
<button class="device-item" *ngFor="let s of pageItems" (click)="choose(s)" style="padding:10px 16px;border-bottom:1px solid #f5f5f5;display:flex;justify-content:space-between;align-items:center;width:100%;text-align:left;"> <button class="device-item" *ngFor="let s of pageItems" (click)="choose(s)" style="padding:10px 16px;border-bottom:1px solid #f5f5f5;display:flex;justify-content:space-between;align-items:center;width:100%;text-align:left;">
<div> <div>
<div class="text-sm font-mono">{{s.station_id}}</div> <div class="text-sm" *ngIf="s.location">{{s.location}}</div>
<div class="text-xs text-gray-500">十进制: {{decId(s)}}</div> <div class="text-xs text-gray-500">设备编号:{{s.station_id}}</div>
<div class="text-xs text-gray-500" *ngIf="s.location">位置: {{s.location}}</div>
</div> </div>
<div class="text-xs text-gray-600">{{s.device_type}}</div> <div class="text-xs" [class.text-green-600]="isOnline(s)" [class.text-gray-500]="!isOnline(s)">{{ isOnline(s) ? '在线' : '离线' }}</div>
</button> </button>
</div> </div>
<div class="device-list-footer" style="padding:10px;border-top:1px solid #eee;display:flex;justify-content:space-between;align-items:center;"> <div class="device-list-footer" style="padding:10px;border-top:1px solid #eee;display:flex;justify-content:space-between;align-items:center;">

View File

@ -1,7 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
type Station = { station_id: string; device_type?: string; name?: string; station_alias?: string; location?: string }; type Station = { station_id: string; device_type?: string; name?: string; station_alias?: string; location?: string; last_update?: string };
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
@ -12,7 +12,7 @@ type Station = { station_id: string; device_type?: string; name?: string; statio
export class HeaderComponent { export class HeaderComponent {
@Input() onlineDevices = 0; @Input() onlineDevices = 0;
@Input() stations: Station[] = []; @Input() stations: Station[] = [];
@Output() selectStation = new EventEmitter<{ stationId: string, decimalId: string }>(); @Output() selectStation = new EventEmitter<{ stationId: string, hex: string }>();
modalOpen = false; modalOpen = false;
currentPage = 1; currentPage = 1;
@ -30,17 +30,23 @@ export class HeaderComponent {
prev() { if (this.currentPage > 1) this.currentPage--; } prev() { if (this.currentPage > 1) this.currentPage--; }
next() { if (this.currentPage < this.totalPages) this.currentPage++; } next() { if (this.currentPage < this.totalPages) this.currentPage++; }
decId(s: Station): string { hexSuffix(s: Station): string {
const id = s.station_id || ''; const id = s.station_id || '';
if (id.length <= 6) return ''; if (id.length <= 6) return id.toUpperCase();
const hex = id.slice(-6); return id.slice(-6).toUpperCase();
const n = parseInt(hex, 16); }
return isNaN(n) ? '' : String(n);
isOnline(s: Station): boolean {
const lu = (s as any).last_update as string | undefined;
if (!lu) return false;
const t = new Date(lu.replace(' ', 'T')).getTime();
if (isNaN(t)) return false;
return Date.now() - t <= 5 * 60 * 1000;
} }
choose(s: Station) { choose(s: Station) {
const dec = this.decId(s); const hex = this.hexSuffix(s);
if (dec) this.selectStation.emit({ stationId: s.station_id, decimalId: dec }); if (hex) this.selectStation.emit({ stationId: s.station_id, hex });
this.close(); this.close();
} }
} }

View File

@ -190,9 +190,15 @@ export class AppComponent implements OnInit, AfterViewInit {
image: new ol.style.Circle({ radius, fill: new ol.style.Fill({ color: 'rgba(0,123,255,0.8)' }), stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) }), image: new ol.style.Circle({ 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 }) text: new ol.style.Text({ text: String(size), fill: new ol.style.Fill({ color: '#fff' }), font: `bold ${fontSize}px Arial`, offsetY: 1 })
}); });
} } else {
const f0 = features[0]; const f0 = features[0];
return this.createStationStyle(f0); const last = f0?.get('lastUpdate');
const online = last ? (new Date(last).getTime() > Date.now() - 5*60*1000) : false;
const color = online ? 'rgba(0,123,255,0.8)' : 'rgba(108,117,125,0.8)';
return new ol.style.Style({
image: new ol.style.Circle({ radius: 6, fill: new ol.style.Fill({ color }), stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) })
});
}
} }
return this.createStationStyle(features[0]); return this.createStationStyle(features[0]);
} }
@ -214,17 +220,21 @@ export class AppComponent implements OnInit, AfterViewInit {
let f = features[0]; let f = features[0];
const subs = f.get('features'); const subs = f.get('features');
if (subs && subs.length>0) { if (subs && subs.length>0) {
// zoom in on cluster instead of selecting
const view = this.map.getView(); const view = this.map.getView();
const z = view.getZoom(); const ex = olAny.extent.createEmpty();
view.animate({ zoom: z + 1, center: evt.coordinate, duration: 200 }); for (const sf of subs) {
olAny.extent.extend(ex, sf.getGeometry().getExtent());
}
view.fit(ex, { duration: 300, maxZoom: 14, padding: [40,40,40,40] });
return; return;
} }
const sid = f.get('stationId'); const sid = f.get('stationId');
const loc = f.get('location') || ''; const loc = f.get('location') || '';
if (!sid) return; if (!sid) return;
const hex = String(sid).slice(-6); const dec = parseInt(hex, 16); const hex = String(sid).slice(-6).toUpperCase();
if (!isNaN(dec)) { this.decimalId = String(dec); await this.query(); this.scrollToChart(); } this.decimalId = hex;
await this.query();
this.scrollToChart();
}); });
this.map.on('pointermove', (evt:any) => { this.map.on('pointermove', (evt:any) => {
const features = this.map.getFeaturesAtPixel(evt.pixel, { layerFilter: (l:any)=> l===this.stationLayer || l===this.clusterLayer }); const features = this.map.getFeaturesAtPixel(evt.pixel, { layerFilter: (l:any)=> l===this.stationLayer || l===this.clusterLayer });
@ -319,17 +329,17 @@ export class AppComponent implements OnInit, AfterViewInit {
await this.loadTileTimes(this.tileProduct); await this.loadTileTimes(this.tileProduct);
} }
private makeStationId(dec: string): string | null { private makeStationIdFromHex(hexRaw: string): string | null {
const n = Number(dec); if (!hexRaw) return null;
if (!isFinite(n)) return null; const hex = String(hexRaw).toUpperCase().replace(/[^0-9A-F]/g, '').padStart(6, '0').slice(-6);
const hex = Math.trunc(n).toString(16).toUpperCase().padStart(6,'0'); if (!hex) return null;
return `RS485-${hex}`; return `RS485-${hex}`;
} }
async query() { async query() {
const dec = this.decimalId.trim(); const dec = this.decimalId.trim();
if (!dec) return; if (!dec) return;
const sid = this.makeStationId(dec); const sid = this.makeStationIdFromHex(dec);
if (!sid) return; if (!sid) return;
const toFmt = (s: string) => s.replace('T',' ') + ':00'; const toFmt = (s: string) => s.replace('T',' ') + ':00';
const from = toFmt(this.start); const from = toFmt(this.start);
@ -357,8 +367,8 @@ export class AppComponent implements OnInit, AfterViewInit {
this.scrollToChart(); this.scrollToChart();
} }
onSelectStation(ev: { stationId: string, decimalId: string }) { onSelectStation(ev: { stationId: string, hex: string }) {
this.decimalId = ev.decimalId; this.decimalId = ev.hex;
this.query(); this.query();
} }

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"weatherstation/core/internal/data" "weatherstation/core/internal/data"
) )
@ -40,18 +41,34 @@ func handleStations(c *gin.Context) {
} }
func handleData(c *gin.Context) { func handleData(c *gin.Context) {
dec := c.Query("decimal_id") idParam := c.Query("hex_id")
startTime := c.Query("start_time") startTime := c.Query("start_time")
endTime := c.Query("end_time") endTime := c.Query("end_time")
interval := c.DefaultQuery("interval", "1hour") interval := c.DefaultQuery("interval", "1hour")
decimalNum, err := strconv.ParseInt(dec, 10, 64) if idParam == "" {
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing hex_id"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid decimal_id"})
return return
} }
hexID := fmt.Sprintf("%06X", decimalNum) upper := strings.ToUpper(idParam)
stationID := fmt.Sprintf("RS485-%s", hexID) var b strings.Builder
for i := 0; i < len(upper); i++ {
ch := upper[i]
if (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F') {
b.WriteByte(ch)
}
}
hex := b.String()
if hex == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hex_id"})
return
}
if len(hex) < 6 {
hex = strings.Repeat("0", 6-len(hex)) + hex
} else if len(hex) > 6 {
hex = hex[len(hex)-6:]
}
stationID := fmt.Sprintf("RS485-%s", hex)
loc, _ := time.LoadLocation("Asia/Shanghai") loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil { if loc == nil {