监控页面增强:温度变化闪绿光、观测N分钟前、更新戳可见
- 每张卡片加"观测 X 分钟前"时间感知 - 温度值变化时卡片边框闪绿光(HTMX afterSwap 事件) - 更新时间戳移入 HTMX 局部刷新区,30s 刷新可见 - CSS 加 obs-age 样式
This commit is contained in:
@@ -5,6 +5,7 @@ pub struct ObsRow {
|
|||||||
pub temp_c: Option<f64>,
|
pub temp_c: Option<f64>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub obs_time: Option<String>,
|
pub obs_time: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get recent temperature observations for an ICAO station.
|
/// Get recent temperature observations for an ICAO station.
|
||||||
@@ -14,7 +15,7 @@ pub fn get_recent_obs(db_path: &str, icao: &str, minutes: i32, limit: usize) ->
|
|||||||
Err(_) => return vec![],
|
Err(_) => return vec![],
|
||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT temp_c, obs_time FROM airport_obs_log \
|
"SELECT temp_c, obs_time, created_at FROM airport_obs_log \
|
||||||
WHERE icao = ?1 AND created_at > datetime('now', '{} minutes') \
|
WHERE icao = ?1 AND created_at > datetime('now', '{} minutes') \
|
||||||
ORDER BY created_at DESC LIMIT ?2",
|
ORDER BY created_at DESC LIMIT ?2",
|
||||||
-minutes
|
-minutes
|
||||||
@@ -28,6 +29,7 @@ pub fn get_recent_obs(db_path: &str, icao: &str, minutes: i32, limit: usize) ->
|
|||||||
Ok(ObsRow {
|
Ok(ObsRow {
|
||||||
temp_c: row.get(0)?,
|
temp_c: row.get(0)?,
|
||||||
obs_time: row.get(1)?,
|
obs_time: row.get(1)?,
|
||||||
|
created_at: row.get(2)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ fn load_city_snapshot(db_path: &str, (key, display, icao, airport, tz, thresh):
|
|||||||
let temps: Vec<f64> = obs.iter().filter_map(|o| o.temp_c).collect();
|
let temps: Vec<f64> = obs.iter().filter_map(|o| o.temp_c).collect();
|
||||||
let current_temp = temps.first().copied();
|
let current_temp = temps.first().copied();
|
||||||
|
|
||||||
|
// Age of most recent observation
|
||||||
|
let obs_age_min = obs.first().and_then(|o| {
|
||||||
|
o.created_at.as_ref().and_then(|ts| {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S")
|
||||||
|
.ok()
|
||||||
|
.map(|dt| {
|
||||||
|
let obs_utc = dt.and_utc();
|
||||||
|
(now_utc - obs_utc).num_minutes().max(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
// Trend from last 6 points
|
// Trend from last 6 points
|
||||||
let trend_data: Vec<f64> = temps.iter().take(6).copied().collect();
|
let trend_data: Vec<f64> = temps.iter().take(6).copied().collect();
|
||||||
let trend = trend::calc_trend(&trend_data);
|
let trend = trend::calc_trend(&trend_data);
|
||||||
@@ -94,6 +106,7 @@ fn load_city_snapshot(db_path: &str, (key, display, icao, airport, tz, thresh):
|
|||||||
temp_ok: false,
|
temp_ok: false,
|
||||||
trend_ok: false,
|
trend_ok: false,
|
||||||
in_window: false,
|
in_window: false,
|
||||||
|
obs_age_min,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub struct CitySnapshot {
|
|||||||
pub temp_ok: bool,
|
pub temp_ok: bool,
|
||||||
pub trend_ok: bool,
|
pub trend_ok: bool,
|
||||||
pub in_window: bool,
|
pub in_window: bool,
|
||||||
|
pub obs_age_min: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ body {
|
|||||||
.runway-label { color: #4a5160; }
|
.runway-label { color: #4a5160; }
|
||||||
.runway-temp { color: #7a8290; font-variant-numeric: tabular-nums; }
|
.runway-temp { color: #7a8290; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ── obs age ── */
|
||||||
|
.obs-age { font-size: 12px; color: #5a6170; }
|
||||||
|
|
||||||
/* ── HTMX indicator ── */
|
/* ── HTMX indicator ── */
|
||||||
.htmx-indicator {
|
.htmx-indicator {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -12,12 +12,14 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>🔥 市场监控</h1>
|
<h1>🔥 市场监控</h1>
|
||||||
<span class="updated">更新 {{ generated_at }}</span>
|
<span class="updated" id="update-time">更新中…</span>
|
||||||
</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">
|
||||||
|
{{ generated_at }}</span>
|
||||||
{% for c in cities %}
|
{% for c in cities %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
@@ -52,6 +54,15 @@
|
|||||||
<span class="label">趋势</span>
|
<span class="label">趋势</span>
|
||||||
<span class="value trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
|
<span class="value trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="label">观测</span>
|
||||||
|
{% match c.obs_age_min %}
|
||||||
|
{% when Some with (m) %}
|
||||||
|
<span class="value obs-age">{{ m }} 分钟前</span>
|
||||||
|
{% when None %}
|
||||||
|
<span class="value na">--</span>
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if !c.runway_pairs.is_empty() %}
|
{% if !c.runway_pairs.is_empty() %}
|
||||||
@@ -73,6 +84,29 @@
|
|||||||
|
|
||||||
{% 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>
|
||||||
|
// flash updated cards whose temp changed
|
||||||
|
(function(){
|
||||||
|
let prev = {};
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||||
|
if(evt.detail.target.id !== 'card-grid') return;
|
||||||
|
document.querySelectorAll('.card').forEach(function(card){
|
||||||
|
var name = card.querySelector('.city-name').textContent;
|
||||||
|
var tv = card.querySelector('.temp-value').textContent;
|
||||||
|
var old = prev[name];
|
||||||
|
prev[name] = tv;
|
||||||
|
if(old && old !== tv && tv !== '--'){
|
||||||
|
card.style.transition = 'none';
|
||||||
|
card.style.boxShadow = '0 0 12px rgba(52,211,153,0.35)';
|
||||||
|
requestAnimationFrame(function(){
|
||||||
|
card.style.transition = 'box-shadow 2s ease-out';
|
||||||
|
card.style.boxShadow = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user