feat: 调整前端页面。加入 Angular 框架,以期减少前端耦合

This commit is contained in:
yarnom 2025-10-11 09:35:07 +08:00
parent 28ce15ce13
commit dc03e83562
14 changed files with 12546 additions and 2 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/log/*
/rain_data/*
export_data/*
/.gopath/*
/tech/*

4
core/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.angular/

View File

@ -0,0 +1,64 @@
{
"$schema": "https://json.schemastore.org/angular-cli",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"weatherstation-ui": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/ui",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": [
"src/styles.css"
],
"scripts": [],
"baseHref": "/ui/"
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "weatherstation-ui:build"
},
"configurations": {
"production": {
"browserTarget": "weatherstation-ui:build:production"
},
"development": {
"browserTarget": "weatherstation-ui:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}

12103
core/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "weatherstation-ui",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "ng serve",
"build": "ng build --configuration production --base-href /ui/",
"dev": "ng serve",
"test": "ng test"
},
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"rxjs": "^7.8.1",
"tslib": "^2.6.2",
"zone.js": "^0.14.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.0",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"typescript": "~5.3.3"
}
}

View File

@ -0,0 +1,4 @@
<div style="font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans, 'Apple Color Emoji', 'Segoe UI Emoji'; padding: 24px;">
<h1>Hello World</h1>
</div>

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>WeatherStation UI</title>
<base href="/ui/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root>Loading...</app-root>
</body>
</html>

View File

@ -0,0 +1,3 @@
// Angular zone support (default change detection)
import 'zone.js';

View File

@ -0,0 +1,4 @@
/* Global styles (optional) */
html, body { height: 100%; margin: 0; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; }

View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./out-tsc",
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"module": "es2022",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"target": "es2022",
"useDefineForClassFields": false,
"lib": [
"es2022",
"dom"
],
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}

View File

@ -4,8 +4,10 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"st
"strconv" "strconv"
"time" "os"
"weatherstation/internal/config" "weatherstation/internal/config"
"weatherstation/internal/database" "weatherstation/internal/database"
"weatherstation/pkg/types" "weatherstation/pkg/types"
@ -27,6 +29,23 @@ func StartGinServer() error {
// 静态文件服务 // 静态文件服务
r.Static("/static", "./static") r.Static("/static", "./static")
// 前端SPAAngular静态资源与路由回退
// 构建产物目录:./core/frontend/dist/ui
r.GET("/ui/*filepath", func(c *gin.Context) {
// 物理文件优先,否则回退到 index.html支持前端路由
requested := c.Param("filepath")
if requested == "" || requested == "/" {
c.File("./core/frontend/dist/ui/index.html")
return
}
full := ".//core/frontend/dist/ui" + requested
if _, err := os.Stat(full); err == nil {
c.File(full)
return
}
c.File("./core/frontend/dist/ui/index.html")
})
// 路由设置 // 路由设置
r.GET("/", indexHandler) r.GET("/", indexHandler)
r.GET("/radar/nanning", radarNanningHandler) r.GET("/radar/nanning", radarNanningHandler)

View File

@ -15,6 +15,10 @@ const WeatherMap = {
this.initializeLayers(); this.initializeLayers();
this.initializeMap(tiandituKey); this.initializeMap(tiandituKey);
this.setupEventListeners(); this.setupEventListeners();
this.setupTileControls();
// 默认不显示(需用户选择),但准备好默认索引
this.tileZ = 7; this.tileY = 40; this.tileX = 102;
this.tileProduct = 'none';
}, },
// 初始化图层 // 初始化图层
@ -56,6 +60,8 @@ const WeatherMap = {
layers.vector, layers.vector,
layers.terrain, layers.terrain,
layers.hybrid, layers.hybrid,
// 栅格瓦片叠加层(动态)
this.createTileOverlayLayer(),
this.clusterLayer, this.clusterLayer,
this.singleStationLayer this.singleStationLayer
], ],
@ -73,6 +79,17 @@ const WeatherMap = {
this.updateLayerVisibility(initialZoom); this.updateLayerVisibility(initialZoom);
}, },
createTileOverlayLayer(){
const layer = new ol.layer.Image({
visible: true,
opacity: 0.8,
source: null,
zIndex: 999
});
this.tileOverlayLayer = layer;
return layer;
},
// 创建地图图层 // 创建地图图层
createMapLayers(tiandituKey) { createMapLayers(tiandituKey) {
return { return {
@ -138,6 +155,220 @@ const WeatherMap = {
}; };
}, },
// ---- 瓦片联动:控件与渲染 ----
setupTileControls(){
const prodSel = document.getElementById('tileProduct');
const timeSel = document.getElementById('tileTimeSelect');
const prevBtn = document.getElementById('btnTilePrev');
const nextBtn = document.getElementById('btnTileNext');
if (!prodSel || !timeSel || !prevBtn || !nextBtn) return;
prodSel.addEventListener('change', async ()=>{
this.tileProduct = prodSel.value;
// 切换产品none 则隐藏图层
if (this.tileProduct === 'none') {
this.tileTimes = [];
this.tileCurrentIdx = -1;
if (this.tileOverlayLayer) this.tileOverlayLayer.setSource(null);
this.updateTileCountInfo();
return;
}
await this.reloadTileTimesAndShow();
});
const sEl = document.getElementById('startDate');
const eEl = document.getElementById('endDate');
const onRangeChange = async ()=>{
if (this.tileProduct && this.tileProduct !== 'none') {
await this.reloadTileTimesAndShow();
}
};
if (sEl) sEl.addEventListener('change', onRangeChange);
if (eEl) eEl.addEventListener('change', onRangeChange);
timeSel.addEventListener('change', async ()=>{
const dt = timeSel.value;
if (!dt) return;
const idx = (this.tileTimes||[]).indexOf(dt);
if (idx >= 0) this.tileCurrentIdx = idx;
await this.loadAndRenderTile(dt);
this.updateTileCountInfo();
});
prevBtn.addEventListener('click', async ()=>{
if (!this.tileTimes || this.tileTimes.length === 0) return;
// times 为倒序:上一时次=更老 => idx+1
if (this.tileCurrentIdx < this.tileTimes.length - 1) {
this.tileCurrentIdx += 1;
const dt = this.tileTimes[this.tileCurrentIdx];
timeSel.value = dt;
await this.loadAndRenderTile(dt);
this.updateTileCountInfo();
}
});
nextBtn.addEventListener('click', async ()=>{
if (!this.tileTimes || this.tileTimes.length === 0) return;
if (this.tileCurrentIdx > 0) {
this.tileCurrentIdx -= 1;
const dt = this.tileTimes[this.tileCurrentIdx];
timeSel.value = dt;
await this.loadAndRenderTile(dt);
this.updateTileCountInfo();
}
});
},
async reloadTileTimesAndShow(){
try{
const times = await this.fetchTileTimes(this.tileProduct, this.tileZ, this.tileY, this.tileX);
this.tileTimes = times || [];
const timeSel = document.getElementById('tileTimeSelect');
if (timeSel){
timeSel.innerHTML = '';
if (this.tileTimes.length === 0){
const opt = document.createElement('option'); opt.value=''; opt.textContent='无可用时次'; timeSel.appendChild(opt);
} else {
this.tileTimes.forEach(dt=>{ const o=document.createElement('option'); o.value=dt; o.textContent=dt; timeSel.appendChild(o); });
this.tileCurrentIdx = 0;
timeSel.value = this.tileTimes[0];
await this.loadAndRenderTile(this.tileTimes[0]);
}
this.updateTileCountInfo();
}
}catch(e){ console.error('加载瓦片时次失败', e); }
},
updateTileCountInfo(){
const el = document.getElementById('tileCountInfo');
if (!el) return;
const total = this.tileTimes ? this.tileTimes.length : 0;
const idxDisp = (this.tileCurrentIdx != null && this.tileCurrentIdx >= 0) ? (this.tileCurrentIdx+1) : 0;
el.textContent = `${total}条,第${idxDisp}`;
const prevBtn = document.getElementById('btnTilePrev');
const nextBtn = document.getElementById('btnTileNext');
if (prevBtn) prevBtn.disabled = !(total>0 && this.tileCurrentIdx < total-1);
if (nextBtn) nextBtn.disabled = !(total>0 && this.tileCurrentIdx > 0);
},
async fetchTileTimes(product, z,y,x){
const sEl = document.getElementById('startDate');
const eEl = document.getElementById('endDate');
let url;
if (product==='rain') url = `/api/rain/times?z=${z}&y=${y}&x=${x}`; else url = `/api/radar/times?z=${z}&y=${y}&x=${x}`;
if (sEl && eEl && sEl.value && eEl.value) {
const from = sEl.value.replace('T',' ') + ':00';
const to = eEl.value.replace('T',' ') + ':00';
url += `&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
} else {
url += '&limit=60';
}
const r = await fetch(url);
if(!r.ok) return [];
const j = await r.json();
return j.times || [];
},
async loadAndRenderTile(dtStr){
try{
const z=this.tileZ,y=this.tileY,x=this.tileX;
const url = this.tileProduct==='rain' ? `/api/rain/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}` : `/api/radar/at?z=${z}&y=${y}&x=${x}&dt=${encodeURIComponent(dtStr)}`;
const r = await fetch(url);
if(!r.ok){ console.warn('瓦片未找到', dtStr); return; }
const t = await r.json();
await this.renderTileOnMap(this.tileProduct, t);
}catch(e){ console.error('加载/渲染瓦片失败', e); }
},
renderTileOnMap(product, t){
if(!t || !t.values) return;
const w=t.width, h=t.height, resDeg=t.res_deg;
const west=t.west, south=t.south, east=t.east, north=t.north;
// 生成画布
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
const imgData = ctx.createImageData(w, h);
// 色带
let colorFunc;
if (product==='rain'){
const edges = [0,5,7.5,10,12.5,15,17.5,20,25,30,40,50,75,100, Infinity];
const colors = [
[255,255,255], [126,212,121], [110,200,109], [97,169,97], [81,148,76], [90,158,112],
[143,194,254], [92,134,245], [66,87,240], [45,48,214], [26,15,166], [63,22,145], [191,70,148], [213,1,146], [213,1,146]
];
colorFunc = (mm)=>{
if (mm===0) return [0,0,0,0]; // 0 值透明
let idx=0; while(idx<edges.length-1 && !(mm>=edges[idx] && mm<edges[idx+1])) idx++;
const c = colors[Math.min(idx, colors.length-1)]; return [c[0],c[1],c[2],220];
};
} else {
// radar dBZ0..75 映射 15 段
const colors = [[0,0,255],[0,191,255],[0,255,255],[127,255,212],[124,252,0],[173,255,47],[255,255,0],[255,215,0],[255,165,0],[255,140,0],[255,69,0],[255,0,0],[220,20,60],[199,21,133],[139,0,139]];
colorFunc = (dbz)=>{
let v = Math.max(0, Math.min(75, dbz));
if (v===0) return [0,0,0,0]; // 0 值透明
let bin = Math.floor(v/5); if (bin>=colors.length) bin=colors.length-1;
const c = colors[bin]; return [c[0],c[1],c[2],220];
};
}
// API 行从南到北?我们按数组行序逐行,从上到下绘制需倒置行
let p=0;
for(let row=0; row<h; row++){
const srcRow = t.values[row] || [];
const dstRow = (h-1-row); // 翻转以匹配北向上
for(let col=0; col<w; col++){
const v = srcRow[col];
let rgba;
if (v==null){ rgba=[0,0,0,0]; }
else { rgba = colorFunc(Number(v)); }
const off = (dstRow*w + col)*4;
imgData.data[off+0]=rgba[0];
imgData.data[off+1]=rgba[1];
imgData.data[off+2]=rgba[2];
imgData.data[off+3]=rgba[3];
}
}
ctx.putImageData(imgData, 0, 0);
// 去除数值文本叠加(应用户要求)
const dataURL = canvas.toDataURL('image/png');
const extent4326 = [west, south, east, north];
const proj = this.map.getView().getProjection();
const extentProj = ol.proj.transformExtent(extent4326, 'EPSG:4326', proj);
const src = new ol.source.ImageStatic({ url: dataURL, imageExtent: extentProj, projection: proj });
this.tileOverlayLayer.setSource(src);
// 保存最近一次用于拾取
this.tileLast = { product, meta: { west, south, east, north, resDeg, width:w, height:h }, values: t.values };
this.setupTileHover();
},
setupTileHover(){
if (this._hoverBound) return;
const tip = document.getElementById('tileValueTooltip');
if (!tip) return;
this.map.on('pointermove', (evt)=>{
try{
if (!this.tileLast || !this.tileOverlayLayer || !this.tileOverlayLayer.getSource()) { tip.style.display='none'; return; }
const coord = this.map.getEventCoordinate(evt.originalEvent);
const lonlat = ol.proj.transform(coord, this.map.getView().getProjection(), 'EPSG:4326');
const {west,south,east,north,resDeg} = this.tileLast.meta;
const lon = lonlat[0], lat = lonlat[1];
if (lon<west || lon>east || lat<south || lat>north) { tip.style.display='none'; return; }
const col = Math.floor((lon - west)/resDeg);
const row = Math.floor((lat - south)/resDeg);
if (row<0 || row>=this.tileLast.meta.height || col<0 || col>=this.tileLast.meta.width) { tip.style.display='none'; return; }
const v = this.tileLast.values[row]?.[col];
if (v==null) { tip.style.display='none'; return; }
const val = Number(v);
// 构建文本
const txt = this.tileLast.product==='rain' ? `${val.toFixed(1)} mm` : `${val.toFixed(1)} dBZ`;
tip.textContent = txt;
// 位置
const pixel = this.map.getPixelFromCoordinate(coord);
tip.style.left = (pixel[0]+10) + 'px';
tip.style.top = (pixel[1]+10) + 'px';
tip.style.display = 'block';
}catch{ tip.style.display='none'; }
});
this._hoverBound = true;
},
// 设置事件监听 // 设置事件监听
setupEventListeners() { setupEventListeners() {
// 监听缩放事件 // 监听缩放事件
@ -441,4 +672,4 @@ const WeatherMap = {
}; };
// 导出地图对象 // 导出地图对象
window.WeatherMap = WeatherMap; window.WeatherMap = WeatherMap;

View File

@ -517,11 +517,35 @@
<button onclick="queryHistoryData()" id="queryBtn" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-3 py-1 rounded">查看历史数据</button> <button onclick="queryHistoryData()" id="queryBtn" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-3 py-1 rounded">查看历史数据</button>
</div> </div>
</div> </div>
<!-- 在时间范围下方增加瓦片联动控制 -->
<div class="control-row flex items-center gap-3 flex-wrap">
<div class="control-group">
<label class="text-sm text-gray-600">地图产品:</label>
<select id="tileProduct" class="px-2 py-1 border border-gray-300 rounded text-sm">
<option value="none" selected>不显示</option>
<option value="rain">一小时降雨</option>
<option value="radar">组合反射率</option>
</select>
</div>
<div class="control-group">
<button id="btnTilePrev" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">上一时次</button>
<span id="tileCountInfo" class="text-xs text-gray-800">共0条第0条</span>
<button id="btnTileNext" class="px-2 py-1 text-sm border border-gray-400 rounded bg-white text-gray-800 hover:bg-gray-100">下一时次</button>
</div>
<div class="control-group">
<label class="text-sm text-gray-600">时间:</label>
<select id="tileTimeSelect" class="px-2 py-1 border border-gray-300 rounded text-sm min-w-[220px]">
<option value="">请选择时间</option>
</select>
</div>
</div>
</div> </div>
<div class="map-container border border-gray-200 rounded" id="mapContainer"> <div class="map-container border border-gray-200 rounded" id="mapContainer">
<div id="map"></div> <div id="map"></div>
<button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button> <button class="map-toggle-btn bg-blue-600 hover:bg-blue-700 text-white" id="toggleMapBtn" onclick="toggleMap()">折叠地图</button>
<div id="tileValueTooltip" style="position:absolute;pointer-events:none;z-index:1003;display:none;background:rgba(0,0,0,0.65);color:#fff;font-size:12px;padding:4px 6px;border-radius:4px;"></div>
</div> </div>
<div class="chart-container" id="chartContainer"> <div class="chart-container" id="chartContainer">