@
添加市场监控 Rust 网页版:Axum+Askama+HTMX,直读 SQLite - 11 城市卡片网格,暗色主题,响应式布局 - 60s HTMX 轮询局部刷新 - 当前温度、今日最高、趋势箭头(线性回归) - 首尔/釜山跑道温度(预留) - 零 Python 依赖,编译后 ~3MB 二进制 Constraint: 读 POLYWEATHER_DB_PATH 指向的 SQLite @
This commit is contained in:
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+1173
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user