监控页面:英文城市名 + 新高浏览器提醒(可开关)
- 卡片标题改为英文名(Seoul/Busan/Tokyo 等)
- 温度突破今日最高 0.3°C 时触发浏览器 Notification
- 右上角 🔔 按钮开关提醒,状态存 localStorage
- 同日同城同一温度不重复提醒(notify_highs 去重)
- 页面标题和标签全英文化
Tested: cargo build --release 通过
This commit is contained in:
+16
-22
@@ -17,19 +17,19 @@ use tower_http::services::ServeDir;
|
||||
use tracing_subscriber;
|
||||
|
||||
// ── city config ──
|
||||
// (city_key, display_name, icao, airport_en, utc_offset_hours, threshold, tz_abbr)
|
||||
const CITIES: &[(&str, &str, &str, &str, i32, f64, &str)] = &[
|
||||
("seoul", "首尔", "RKSI", "Incheon", 9, 3.0, "KST"),
|
||||
("busan", "釜山", "RKPK", "Gimhae", 9, 2.0, "KST"),
|
||||
("tokyo", "东京", "44166", "Haneda", 9, 2.0, "JST"),
|
||||
("ankara", "安卡拉", "17128", "Esenboğa", 3, 3.0, "TRT"),
|
||||
("helsinki", "赫尔辛基", "EFHK", "Vantaa", 3, 2.0, "EEST"),
|
||||
("amsterdam","阿姆斯特丹","EHAM","Schiphol",2,2.0,"CEST"),
|
||||
("istanbul","伊斯坦布尔","17058","Airport",3,3.0,"TRT"),
|
||||
("paris", "巴黎", "LFPB", "Le Bourget", 2, 3.0, "CEST"),
|
||||
("hong kong","香港","HKO","Observatory",8,1.5,"HKT"),
|
||||
("lau fau shan","流浮山","LFS","Lau Fau Shan",8,1.5,"HKT"),
|
||||
("taipei", "台北", "466920", "Songshan", 8, 1.5, "TST"),
|
||||
// (key, zh_name, en_name, icao, airport_en, utc_offset_hours, threshold, tz_abbr)
|
||||
const CITIES: &[(&str, &str, &str, &str, &str, i32, f64, &str)] = &[
|
||||
("seoul", "首尔", "Seoul", "RKSI", "Incheon", 9, 3.0, "KST"),
|
||||
("busan", "釜山", "Busan", "RKPK", "Gimhae", 9, 2.0, "KST"),
|
||||
("tokyo", "东京", "Tokyo", "44166", "Haneda", 9, 2.0, "JST"),
|
||||
("ankara", "安卡拉", "Ankara", "17128", "Esenboğa", 3, 3.0, "TRT"),
|
||||
("helsinki", "赫尔辛基", "Helsinki", "EFHK", "Vantaa", 3, 2.0, "EEST"),
|
||||
("amsterdam","阿姆斯特丹","Amsterdam","EHAM","Schiphol",2,2.0,"CEST"),
|
||||
("istanbul","伊斯坦布尔","Istanbul","17058","Airport",3,3.0,"TRT"),
|
||||
("paris", "巴黎", "Paris", "LFPB", "Le Bourget", 2, 3.0, "CEST"),
|
||||
("hong kong","香港","Hong Kong","HKO","Observatory",8,1.5,"HKT"),
|
||||
("lau fau shan","流浮山","Lau Fau Shan","LFS","Lau Fau Shan",8,1.5,"HKT"),
|
||||
("taipei", "台北", "Taipei", "466920", "Songshan", 8, 1.5, "TST"),
|
||||
];
|
||||
|
||||
// ── app state ──
|
||||
@@ -49,7 +49,7 @@ struct MonitorTemplate {
|
||||
|
||||
// ── 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 local = now_utc + chrono::Duration::hours(*tz as i64);
|
||||
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,
|
||||
};
|
||||
|
||||
// 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 {
|
||||
name: key.to_string(),
|
||||
display_name: display.to_string(),
|
||||
en_name: en.to_string(),
|
||||
airport: airport.to_string(),
|
||||
icao: icao.to_string(),
|
||||
local_time,
|
||||
@@ -99,7 +95,7 @@ fn load_city_snapshot(db_path: &str, (key, display, icao, airport, tz, thresh, t
|
||||
max_time: None,
|
||||
trend,
|
||||
new_high,
|
||||
runway_pairs,
|
||||
runway_pairs: vec![],
|
||||
gap: None,
|
||||
threshold: *thresh,
|
||||
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}")))
|
||||
}
|
||||
|
||||
// ── main ──
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde::Serialize;
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CitySnapshot {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub en_name: String,
|
||||
pub airport: String,
|
||||
pub icao: String,
|
||||
pub local_time: String,
|
||||
|
||||
@@ -28,6 +28,22 @@ body {
|
||||
color: #e8eaed;
|
||||
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 ── */
|
||||
.card-grid {
|
||||
|
||||
@@ -4,31 +4,33 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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">
|
||||
<meta http-equiv="refresh" content="120">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<h1>🔥 市场监控</h1>
|
||||
<span class="updated" id="update-time">更新中…</span>
|
||||
<h1>🔥 Market Monitor</h1>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<div id="card-grid" class="card-grid"
|
||||
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>
|
||||
{% 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">
|
||||
<span class="city-name">{{ c.display_name }}</span>
|
||||
<span class="city-name">{{ c.en_name }}</span>
|
||||
<span class="airport">/ {{ c.airport }}</span>
|
||||
<span class="local-time">{{ c.local_time }}</span>
|
||||
{% if c.new_high %}
|
||||
<span class="badge new-high">◆新高</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-temp">
|
||||
@@ -42,7 +44,7 @@
|
||||
|
||||
<div class="card-meta">
|
||||
<div class="meta-row">
|
||||
<span class="label">今日最高</span>
|
||||
<span class="label">Today High</span>
|
||||
{% match c.today_max %}
|
||||
{% when Some with (m) %}
|
||||
<span class="value">{{ "{:.1}"|format(m) }}°C</span>
|
||||
@@ -52,10 +54,10 @@
|
||||
<span class="trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="label">观测</span>
|
||||
<span class="label">Obs</span>
|
||||
{% match c.obs_age_min %}
|
||||
{% when Some with (m) %}
|
||||
<span class="value obs-age">{{ m }} 分钟前</span>
|
||||
<span class="value obs-age">{{ m }} min ago</span>
|
||||
{% when None %}
|
||||
<span class="value na">--</span>
|
||||
{% endmatch %}
|
||||
@@ -65,12 +67,63 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="spinner" class="htmx-indicator">刷新中…</div>
|
||||
<div id="spinner" class="htmx-indicator">Refreshing…</div>
|
||||
|
||||
{% if full_page %}
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></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(){
|
||||
let prev = {};
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||
@@ -80,14 +133,23 @@
|
||||
var tv = card.querySelector('.temp-value').textContent;
|
||||
var old = prev[name];
|
||||
prev[name] = tv;
|
||||
|
||||
// Flash if changed
|
||||
if(old && old !== tv && tv !== '--'){
|
||||
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(){
|
||||
card.style.transition = 'box-shadow 2s ease-out';
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user