监控页面:英文城市名 + 新高浏览器提醒(可开关)

- 卡片标题改为英文名(Seoul/Busan/Tokyo 等)
- 温度突破今日最高 0.3°C 时触发浏览器 Notification
- 右上角 🔔 按钮开关提醒,状态存 localStorage
- 同日同城同一温度不重复提醒(notify_highs 去重)
- 页面标题和标签全英文化

Tested: cargo build --release 通过
This commit is contained in:
2569718930@qq.com
2026-05-13 20:33:59 +08:00
parent 041c9fb4fa
commit 88918677c0
4 changed files with 110 additions and 38 deletions
+16 -22
View File
@@ -17,19 +17,19 @@ use tower_http::services::ServeDir;
use tracing_subscriber; use tracing_subscriber;
// ── city config ── // ── city config ──
// (city_key, display_name, icao, airport_en, utc_offset_hours, threshold, tz_abbr) // (key, zh_name, en_name, icao, airport_en, utc_offset_hours, threshold, tz_abbr)
const CITIES: &[(&str, &str, &str, &str, i32, f64, &str)] = &[ const CITIES: &[(&str, &str, &str, &str, &str, i32, f64, &str)] = &[
("seoul", "首尔", "RKSI", "Incheon", 9, 3.0, "KST"), ("seoul", "首尔", "Seoul", "RKSI", "Incheon", 9, 3.0, "KST"),
("busan", "釜山", "RKPK", "Gimhae", 9, 2.0, "KST"), ("busan", "釜山", "Busan", "RKPK", "Gimhae", 9, 2.0, "KST"),
("tokyo", "东京", "44166", "Haneda", 9, 2.0, "JST"), ("tokyo", "东京", "Tokyo", "44166", "Haneda", 9, 2.0, "JST"),
("ankara", "安卡拉", "17128", "Esenboğa", 3, 3.0, "TRT"), ("ankara", "安卡拉", "Ankara", "17128", "Esenboğa", 3, 3.0, "TRT"),
("helsinki", "赫尔辛基", "EFHK", "Vantaa", 3, 2.0, "EEST"), ("helsinki", "赫尔辛基", "Helsinki", "EFHK", "Vantaa", 3, 2.0, "EEST"),
("amsterdam","阿姆斯特丹","EHAM","Schiphol",2,2.0,"CEST"), ("amsterdam","阿姆斯特丹","Amsterdam","EHAM","Schiphol",2,2.0,"CEST"),
("istanbul","伊斯坦布尔","17058","Airport",3,3.0,"TRT"), ("istanbul","伊斯坦布尔","Istanbul","17058","Airport",3,3.0,"TRT"),
("paris", "巴黎", "LFPB", "Le Bourget", 2, 3.0, "CEST"), ("paris", "巴黎", "Paris", "LFPB", "Le Bourget", 2, 3.0, "CEST"),
("hong kong","香港","HKO","Observatory",8,1.5,"HKT"), ("hong kong","香港","Hong Kong","HKO","Observatory",8,1.5,"HKT"),
("lau fau shan","流浮山","LFS","Lau Fau Shan",8,1.5,"HKT"), ("lau fau shan","流浮山","Lau Fau Shan","LFS","Lau Fau Shan",8,1.5,"HKT"),
("taipei", "台北", "466920", "Songshan", 8, 1.5, "TST"), ("taipei", "台北", "Taipei", "466920", "Songshan", 8, 1.5, "TST"),
]; ];
// ── app state ── // ── app state ──
@@ -49,7 +49,7 @@ struct MonitorTemplate {
// ── data loading ── // ── data loading ──
fn load_city_snapshot(db_path: &str, (key, display, icao, airport, tz, thresh, tz_abbr): &(&str, &str, &str, &str, i32, f64, &str)) -> CitySnapshot { fn load_city_snapshot(db_path: &str, (key, _zh, en, icao, airport, tz, thresh, tz_abbr): &(&str, &str, &str, &str, &str, i32, f64, &str)) -> CitySnapshot {
let now_utc = Utc::now(); let now_utc = Utc::now();
let local = now_utc + chrono::Duration::hours(*tz as i64); let local = now_utc + chrono::Duration::hours(*tz as i64);
let local_time = format!("{} {}", local.format("%H:%M"), tz_abbr); let local_time = format!("{} {}", local.format("%H:%M"), tz_abbr);
@@ -84,13 +84,9 @@ fn load_city_snapshot(db_path: &str, (key, display, icao, airport, tz, thresh, t
_ => false, _ => false,
}; };
// For AMOS cities, we'd need runway data from a different DB table.
// Simplified: no runway pairs for now; can be added later.
let runway_pairs: Vec<(String, f64)> = vec![];
CitySnapshot { CitySnapshot {
name: key.to_string(), name: key.to_string(),
display_name: display.to_string(), en_name: en.to_string(),
airport: airport.to_string(), airport: airport.to_string(),
icao: icao.to_string(), icao: icao.to_string(),
local_time, local_time,
@@ -99,7 +95,7 @@ fn load_city_snapshot(db_path: &str, (key, display, icao, airport, tz, thresh, t
max_time: None, max_time: None,
trend, trend,
new_high, new_high,
runway_pairs, runway_pairs: vec![],
gap: None, gap: None,
threshold: *thresh, threshold: *thresh,
time_ok: false, time_ok: false,
@@ -140,8 +136,6 @@ async fn api_data(State(state): State<Arc<AppState>>) -> impl IntoResponse {
Html(tmpl.render().unwrap_or_else(|e| format!("Template error: {e}"))) Html(tmpl.render().unwrap_or_else(|e| format!("Template error: {e}")))
} }
// ── main ──
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
+1 -1
View File
@@ -3,7 +3,7 @@ use serde::Serialize;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct CitySnapshot { pub struct CitySnapshot {
pub name: String, pub name: String,
pub display_name: String, pub en_name: String,
pub airport: String, pub airport: String,
pub icao: String, pub icao: String,
pub local_time: String, pub local_time: String,
+16
View File
@@ -28,6 +28,22 @@ body {
color: #e8eaed; color: #e8eaed;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.notify-btn {
background: none;
border: 1px solid #2a2e40;
border-radius: 6px;
padding: 3px 8px;
font-size: 16px;
cursor: pointer;
transition: border-color 0.2s;
}
.notify-btn:hover { border-color: #4a5160; }
.notify-btn.muted { opacity: 0.4; }
/* ── grid ── */ /* ── grid ── */
.card-grid { .card-grid {
+77 -15
View File
@@ -4,31 +4,33 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>市场监控 — PolyWeather</title> <title>Market Monitor — PolyWeather</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<meta http-equiv="refresh" content="120"> <meta http-equiv="refresh" content="120">
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<header class="header"> <header class="header">
<h1>🔥 市场监控</h1> <h1>🔥 Market Monitor</h1>
<span class="updated" id="update-time">更新中…</span> <div class="header-right">
<button id="notify-toggle" class="notify-btn" onclick="toggleNotify()" title="新高提醒">
<span id="notify-icon">🔔</span>
</button>
<span class="updated" id="update-time">更新中…</span>
</div>
</header> </header>
{% endif %} {% endif %}
<div id="card-grid" class="card-grid" <div id="card-grid" class="card-grid"
hx-get="/api/data" hx-trigger="every 30s" hx-swap="outerHTML" hx-indicator="#spinner"> hx-get="/api/data" hx-trigger="every 30s" hx-swap="outerHTML" hx-indicator="#spinner">
<span id="update-time" class="updated" style="display:block;grid-column:1/-1;text-align:right;margin-bottom:2px"> <span class="updated" style="display:block;grid-column:1/-1;text-align:right;margin-bottom:2px">
{{ generated_at }}</span> {{ generated_at }}</span>
{% for c in cities %} {% for c in cities %}
<div class="card{% if c.new_high %} new-high-card{% endif %}"> <div class="card{% if c.new_high %} new-high-card{% endif %}" data-new-high="{{ c.new_high }}">
<div class="card-top"> <div class="card-top">
<span class="city-name">{{ c.display_name }}</span> <span class="city-name">{{ c.en_name }}</span>
<span class="airport">/ {{ c.airport }}</span> <span class="airport">/ {{ c.airport }}</span>
<span class="local-time">{{ c.local_time }}</span> <span class="local-time">{{ c.local_time }}</span>
{% if c.new_high %}
<span class="badge new-high">◆新高</span>
{% endif %}
</div> </div>
<div class="card-temp"> <div class="card-temp">
@@ -42,7 +44,7 @@
<div class="card-meta"> <div class="card-meta">
<div class="meta-row"> <div class="meta-row">
<span class="label">今日最高</span> <span class="label">Today High</span>
{% match c.today_max %} {% match c.today_max %}
{% when Some with (m) %} {% when Some with (m) %}
<span class="value">{{ "{:.1}"|format(m) }}°C</span> <span class="value">{{ "{:.1}"|format(m) }}°C</span>
@@ -52,10 +54,10 @@
<span class="trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span> <span class="trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="label">观测</span> <span class="label">Obs</span>
{% match c.obs_age_min %} {% match c.obs_age_min %}
{% when Some with (m) %} {% when Some with (m) %}
<span class="value obs-age">{{ m }} 分钟前</span> <span class="value obs-age">{{ m }} min ago</span>
{% when None %} {% when None %}
<span class="value na">--</span> <span class="value na">--</span>
{% endmatch %} {% endmatch %}
@@ -65,12 +67,63 @@
{% endfor %} {% endfor %}
</div> </div>
<div id="spinner" class="htmx-indicator">刷新中</div> <div id="spinner" class="htmx-indicator">Refreshing</div>
{% if full_page %} {% if full_page %}
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script> <script>
// flash updated cards whose temp changed // ── Notification ──
var NOTIFY_ENABLED = localStorage.getItem('monitor_notify') !== 'off';
var NOTIFIED_HIGHS = JSON.parse(localStorage.getItem('monitor_notified_highs') || '{}');
function toggleNotify() {
NOTIFY_ENABLED = !NOTIFY_ENABLED;
localStorage.setItem('monitor_notify', NOTIFY_ENABLED ? 'on' : 'off');
updateNotifyIcon();
if (NOTIFY_ENABLED && Notification.permission === 'default') {
Notification.requestPermission();
}
}
function updateNotifyIcon() {
var icon = document.getElementById('notify-icon');
var btn = document.getElementById('notify-toggle');
if (NOTIFY_ENABLED) {
icon.textContent = '🔔';
btn.classList.remove('muted');
} else {
icon.textContent = '🔕';
btn.classList.add('muted');
}
}
function fireNotification(city, enName, temp, todayMax) {
if (!NOTIFY_ENABLED) return;
if (Notification.permission !== 'granted') return;
var key = enName + '|' + temp;
var today = new Date().toDateString();
// Reset notified list on new day
if (NOTIFIED_HIGHS._day !== today) {
NOTIFIED_HIGHS = { _day: today };
}
if (NOTIFIED_HIGHS[key]) return; // already notified
NOTIFIED_HIGHS[key] = true;
localStorage.setItem('monitor_notified_highs', JSON.stringify(NOTIFIED_HIGHS));
new Notification('🔴 New High — ' + enName, {
body: temp + '°C (was ' + todayMax + '°C)\n' + enName + ' / ' + city,
icon: '/static/favicon.png',
tag: key,
requireInteraction: true,
});
}
updateNotifyIcon();
// ── Flash on temp change + notifications ──
(function(){ (function(){
let prev = {}; let prev = {};
document.body.addEventListener('htmx:afterSwap', function(evt){ document.body.addEventListener('htmx:afterSwap', function(evt){
@@ -80,14 +133,23 @@
var tv = card.querySelector('.temp-value').textContent; var tv = card.querySelector('.temp-value').textContent;
var old = prev[name]; var old = prev[name];
prev[name] = tv; prev[name] = tv;
// Flash if changed
if(old && old !== tv && tv !== '--'){ if(old && old !== tv && tv !== '--'){
card.style.transition = 'none'; card.style.transition = 'none';
card.style.boxShadow = '0 0 12px rgba(52,211,153,0.35)'; card.style.boxShadow = '0 0 14px rgba(52,211,153,0.40)';
requestAnimationFrame(function(){ requestAnimationFrame(function(){
card.style.transition = 'box-shadow 2s ease-out'; card.style.transition = 'box-shadow 2s ease-out';
card.style.boxShadow = ''; card.style.boxShadow = '';
}); });
} }
// Notification for new high
if(card.dataset.newHigh === 'true'){
var maxEl = card.querySelector('.meta-row .value');
var maxVal = maxEl ? maxEl.textContent.replace('°C','') : '';
fireNotification(name, name, tv, maxVal);
}
}); });
}); });
})(); })();