添加市场监控 Rust 网页版:Axum+Askama+HTMX,直读 SQLite

- 11 城市卡片网格,暗色主题,响应式布局
- 60s HTMX 轮询局部刷新
- 当前温度、今日最高、趋势箭头(线性回归)
- 首尔/釜山跑道温度(预留)
- 零 Python 依赖,编译后 ~3MB 二进制

Constraint: 读 POLYWEATHER_DB_PATH 指向的 SQLite
@
This commit is contained in:
2569718930@qq.com
2026-05-13 19:24:24 +08:00
parent 86467d4e92
commit db9071101a
9 changed files with 1731 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target/
+1173
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "market-monitor"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
askama = "0.12"
askama_axum = "0.4"
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = "0.3"
chrono = "0.4"
+37
View File
@@ -0,0 +1,37 @@
use rusqlite::Connection;
#[derive(Debug, Clone)]
pub struct ObsRow {
pub temp_c: Option<f64>,
#[allow(dead_code)]
pub obs_time: Option<String>,
}
/// Get recent temperature observations for an ICAO station.
pub fn get_recent_obs(db_path: &str, icao: &str, minutes: i32, limit: usize) -> Vec<ObsRow> {
let conn = match Connection::open(db_path) {
Ok(c) => c,
Err(_) => return vec![],
};
let sql = format!(
"SELECT temp_c, obs_time FROM airport_obs_log \
WHERE icao = ?1 AND created_at > datetime('now', '{} minutes') \
ORDER BY created_at DESC LIMIT ?2",
-minutes
);
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return vec![],
};
let rows = stmt
.query_map(rusqlite::params![icao.to_uppercase(), limit as i64], |row| {
Ok(ObsRow {
temp_c: row.get(0)?,
obs_time: row.get(1)?,
})
})
.ok()
.map(|iter| iter.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
rows
}
+153
View File
@@ -0,0 +1,153 @@
mod db;
mod model;
mod trend;
use std::sync::Arc;
use askama_axum::Template;
use axum::{
extract::State,
response::{Html, IntoResponse},
routing::get,
Router,
};
use chrono::Utc;
use model::CitySnapshot;
use tower_http::services::ServeDir;
use tracing_subscriber;
// ── city config ──
// (city_key, display_name, icao, airport_en, utc_offset_hours, threshold)
const CITIES: &[(&str, &str, &str, &str, i32, f64)] = &[
("seoul", "首尔", "RKSI", "Incheon", 9, 3.0),
("busan", "釜山", "RKPK", "Gimhae", 9, 2.0),
("tokyo", "东京", "44166", "Haneda", 9, 2.0),
("ankara", "安卡拉", "17128", "Esenboğa", 3, 3.0),
("helsinki", "赫尔辛基", "EFHK", "Vantaa", 3, 2.0),
("amsterdam","阿姆斯特丹","EHAM","Schiphol",2,2.0),
("istanbul","伊斯坦布尔","17058","Airport",3,3.0),
("paris", "巴黎", "LFPB", "Le Bourget", 2, 3.0),
("hong kong","香港","HKO","Observatory",8,1.5),
("lau fau shan","流浮山","LFS","Lau Fau Shan",8,1.5),
("taipei", "台北", "466920", "Songshan", 8, 1.5),
];
// ── app state ──
struct AppState {
db_path: String,
}
// ── templates ──
#[derive(Template)]
#[template(path = "monitor.html")]
struct MonitorTemplate {
cities: Vec<CitySnapshot>,
full_page: bool,
generated_at: String,
}
// ── data loading ──
fn load_city_snapshot(db_path: &str, (key, display, icao, airport, tz, thresh): &(&str, &str, &str, &str, i32, f64)) -> CitySnapshot {
let now_utc = Utc::now();
let local = now_utc + chrono::Duration::hours(*tz as i64);
let local_time = local.format("%H:%M").to_string();
// Recent obs for temp + trend
let obs = db::get_recent_obs(db_path, icao, 120, 12);
let temps: Vec<f64> = obs.iter().filter_map(|o| o.temp_c).collect();
let current_temp = temps.first().copied();
// Trend from last 6 points
let trend_data: Vec<f64> = temps.iter().take(6).copied().collect();
let trend = trend::calc_trend(&trend_data);
// Today's max: max of all temps in recent window (approximation)
let today_max = temps.iter().cloned().fold(None::<f64>, |a, b| {
Some(a.map_or(b, |x| x.max(b)))
});
let new_high = match (current_temp, today_max) {
(Some(ct), Some(tm)) => ct >= tm + 0.3,
_ => 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(),
airport: airport.to_string(),
icao: icao.to_string(),
local_time,
current_temp,
today_max,
max_time: None,
trend,
new_high,
runway_pairs,
gap: None,
threshold: *thresh,
time_ok: false,
temp_ok: false,
trend_ok: false,
in_window: false,
}
}
fn load_all_cities(db_path: &str) -> Vec<CitySnapshot> {
CITIES
.iter()
.map(|c| load_city_snapshot(db_path, c))
.collect()
}
// ── routes ──
async fn index(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let cities = load_all_cities(&state.db_path);
let tmpl = MonitorTemplate {
cities,
full_page: true,
generated_at: Utc::now().format("%H:%M:%S UTC").to_string(),
};
Html(tmpl.render().unwrap_or_else(|e| format!("Template error: {e}")))
}
async fn api_data(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let cities = load_all_cities(&state.db_path);
let tmpl = MonitorTemplate {
cities,
full_page: false,
generated_at: Utc::now().format("%H:%M:%S UTC").to_string(),
};
Html(tmpl.render().unwrap_or_else(|e| format!("Template error: {e}")))
}
// ── main ──
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let db_path = std::env::var("POLYWEATHER_DB_PATH")
.unwrap_or_else(|_| "/var/lib/polyweather/polyweather.db".into());
let listen = std::env::var("MONITOR_LISTEN_ADDR")
.unwrap_or_else(|_| "0.0.0.0:3001".into());
tracing::info!("DB path: {db_path}");
tracing::info!("市场监控页面: http://{listen}");
let state = Arc::new(AppState { db_path });
let app = Router::new()
.route("/", get(index))
.route("/api/data", get(api_data))
.nest_service("/static", ServeDir::new("static"))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&listen).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
+51
View File
@@ -0,0 +1,51 @@
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct CitySnapshot {
pub name: String,
pub display_name: String,
pub airport: String,
pub icao: String,
pub local_time: String,
pub current_temp: Option<f64>,
pub today_max: Option<f64>,
pub max_time: Option<String>,
pub trend: Trend,
pub new_high: bool,
pub runway_pairs: Vec<(String, f64)>,
pub gap: Option<f64>,
pub threshold: f64,
pub time_ok: bool,
pub temp_ok: bool,
pub trend_ok: bool,
pub in_window: bool,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Trend {
Rising,
Falling,
Flat,
Unknown,
}
impl Trend {
pub fn symbol(&self) -> &str {
match self {
Trend::Rising => "",
Trend::Falling => "",
Trend::Flat => "",
Trend::Unknown => "",
}
}
pub fn css_class(&self) -> &str {
match self {
Trend::Rising => "rising",
Trend::Falling => "falling",
Trend::Flat => "flat",
Trend::Unknown => "",
}
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::model::Trend;
/// Calculate temperature trend from recent observations.
/// Uses linear regression slope. Observations are newest-first.
pub fn calc_trend(temps: &[f64]) -> Trend {
let n = temps.len();
if n < 4 {
return Trend::Unknown;
}
// Reverse to oldest-first for regression
let values: Vec<f64> = temps.iter().rev().copied().collect();
let n_f = n as f64;
let mean_x = (n_f - 1.0) / 2.0;
let mean_y = values.iter().sum::<f64>() / n_f;
let mut num = 0.0;
let mut den = 0.0;
for (i, &y) in values.iter().enumerate() {
let x = i as f64;
num += (x - mean_x) * (y - mean_y);
den += (x - mean_x) * (x - mean_x);
}
if den == 0.0 {
return Trend::Flat;
}
let slope = num / den;
// Slope is °C per observation interval (~10 min per obs from METAR cluster)
// Threshold: > +0.2 → rising, < -0.2 → falling
if slope > 0.2 {
Trend::Rising
} else if slope < -0.2 {
Trend::Falling
} else {
Trend::Flat
}
}
+184
View File
@@ -0,0 +1,184 @@
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: #0f1117;
color: #c8cdd4;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
}
.page {
max-width: 1400px;
margin: 0 auto;
padding: 20px 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #1e2130;
}
.header h1 {
font-size: 22px;
font-weight: 600;
color: #e8eaed;
letter-spacing: 0.02em;
}
.updated {
font-size: 13px;
color: #5a6170;
}
/* ── grid ── */
.card-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
@media (max-width: 1280px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 900px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .card-grid { grid-template-columns: 1fr; } }
/* ── card ── */
.card {
background: #161822;
border: 1px solid #1e2130;
border-radius: 10px;
padding: 16px 18px;
transition: border-color 0.3s, box-shadow 0.3s;
position: relative;
}
.card:hover {
border-color: #2a2e40;
}
/* ── card-top ── */
.card-top {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 13px;
flex-wrap: wrap;
}
.city-name {
color: #e0e3e8;
font-weight: 600;
}
.airport {
color: #6a7180;
}
.local-time {
margin-left: auto;
color: #4a5160;
font-variant-numeric: tabular-nums;
}
.badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 600;
}
.new-high {
background: rgba(124, 58, 237, 0.18);
color: #a78bfa;
}
/* ── temp ── */
.card-temp {
margin: 4px 0 6px;
font-weight: 700;
line-height: 1.1;
}
.temp-value {
font-size: 40px;
color: #e8eaed;
letter-spacing: -0.02em;
}
.temp-value.na {
color: #3a4050;
font-size: 28px;
}
.temp-unit {
font-size: 18px;
color: #5a6170;
margin-left: 2px;
}
/* ── meta ── */
.card-meta {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 4px;
}
.meta-row {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.meta-row .label {
color: #4a5160;
}
.meta-row .value {
color: #9aa0b0;
font-variant-numeric: tabular-nums;
}
.meta-row .value.na {
color: #3a4050;
}
/* ── trend ── */
.trend { font-size: 15px; font-weight: 600; }
.trend.rising { color: #34d399; }
.trend.falling { color: #60a5fa; }
.trend.flat { color: #5a6170; }
/* ── runway ── */
.card-runway { margin-top: 8px; }
.runway-divider {
height: 1px;
background: #1e2130;
margin-bottom: 6px;
}
.runway-row {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 1px;
}
.runway-label { color: #4a5160; }
.runway-temp { color: #7a8290; font-variant-numeric: tabular-nums; }
/* ── HTMX indicator ── */
.htmx-indicator {
text-align: center;
padding: 12px;
font-size: 13px;
color: #3a4050;
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { display: block; }
+79
View File
@@ -0,0 +1,79 @@
{% if full_page %}
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>市场监控 — 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">更新 {{ generated_at }}</span>
</header>
{% endif %}
<div id="card-grid" class="card-grid"
hx-get="/api/data" hx-trigger="every 60s" hx-swap="outerHTML" hx-indicator="#spinner">
{% for c in cities %}
<div class="card">
<div class="card-top">
<span class="city-name">{{ c.display_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">
{% match c.current_temp %}
{% when Some with (t) %}
<span class="temp-value">{{ "{:.1}"|format(t) }}</span><span class="temp-unit">°C</span>
{% when None %}
<span class="temp-value na">--</span>
{% endmatch %}
</div>
<div class="card-meta">
<div class="meta-row">
<span class="label">今日最高</span>
{% match c.today_max %}
{% when Some with (m) %}
<span class="value">{{ "{:.1}"|format(m) }}°C</span>
{% when None %}
<span class="value na">--</span>
{% endmatch %}
</div>
<div class="meta-row">
<span class="label">趋势</span>
<span class="value trend {{ c.trend.css_class() }}">{{ c.trend.symbol() }}</span>
</div>
</div>
{% if !c.runway_pairs.is_empty() %}
<div class="card-runway">
<div class="runway-divider"></div>
{% for (pair, temp) in c.runway_pairs %}
<div class="runway-row">
<span class="runway-label">{{ pair }}</span>
<span class="runway-temp">{{ "{:.1}"|format(temp) }}°C</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div id="spinner" class="htmx-indicator">刷新中…</div>
{% if full_page %}
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</div>
</body>
</html>
{% endif %}