用 Python 重写市场监控网页版:FastAPI+Jinja2+HTMX,复用 _analyze()
This commit is contained in:
@@ -22,8 +22,10 @@ from web.analysis_service import ( # noqa: E402
|
||||
)
|
||||
from web.core import app # noqa: E402
|
||||
from web.routes import router # noqa: E402
|
||||
from web.monitor_routes import router as monitor_router # noqa: E402
|
||||
|
||||
app.include_router(router)
|
||||
app.include_router(monitor_router)
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""市场监控网页版 — 寄生 FastAPI,复用 _analyze() 全量数据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from loguru import logger
|
||||
|
||||
from web.analysis_service import _analyze
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="web/templates")
|
||||
|
||||
# ── city config (与 telegram_push 一致) ──
|
||||
|
||||
_CITIES: List[Dict[str, Any]] = [
|
||||
{"key": "seoul", "en_name": "Seoul", "icao": "RKSI", "airport": "Incheon", "tz": 9, "tz_abbr": "KST", "rw": True},
|
||||
{"key": "busan", "en_name": "Busan", "icao": "RKPK", "airport": "Gimhae", "tz": 9, "tz_abbr": "KST", "rw": True},
|
||||
{"key": "tokyo", "en_name": "Tokyo", "icao": "44166", "airport": "Haneda", "tz": 9, "tz_abbr": "JST", "rw": False},
|
||||
{"key": "ankara", "en_name": "Ankara", "icao": "17128", "airport": "Esenboğa", "tz": 3, "tz_abbr": "TRT", "rw": False},
|
||||
{"key": "helsinki", "en_name": "Helsinki", "icao": "EFHK", "airport": "Vantaa", "tz": 3, "tz_abbr": "EEST", "rw": False},
|
||||
{"key": "amsterdam", "en_name": "Amsterdam", "icao": "EHAM", "airport": "Schiphol", "tz": 2, "tz_abbr": "CEST", "rw": False},
|
||||
{"key": "istanbul", "en_name": "Istanbul", "icao": "17058", "airport": "Airport", "tz": 3, "tz_abbr": "TRT", "rw": False},
|
||||
{"key": "paris", "en_name": "Paris", "icao": "LFPB", "airport": "Le Bourget", "tz": 2, "tz_abbr": "CEST", "rw": False},
|
||||
{"key": "hong kong", "en_name": "Hong Kong", "icao": "HKO", "airport": "Observatory", "tz": 8, "tz_abbr": "HKT", "rw": False},
|
||||
{"key": "lau fau shan","en_name": "Lau Fau Shan","icao": "LFS", "airport": "Lau Fau Shan", "tz": 8, "tz_abbr": "HKT", "rw": False},
|
||||
{"key": "taipei", "en_name": "Taipei", "icao": "466920", "airport": "Songshan", "tz": 8, "tz_abbr": "TST", "rw": False},
|
||||
]
|
||||
|
||||
# ── helpers ──
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
"""Safe float."""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(v), 1)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _trend_info(icao: str) -> tuple[str, str]:
|
||||
"""Return (symbol, css_class) from _check_rising_trend."""
|
||||
try:
|
||||
from src.utils.telegram_push import _check_rising_trend
|
||||
ok = _check_rising_trend(icao)
|
||||
except Exception:
|
||||
return ("→", "flat")
|
||||
if ok:
|
||||
return ("↑", "rising")
|
||||
# Check if falling (temp decreasing)
|
||||
try:
|
||||
from src.database.db_manager import DBManager
|
||||
obs = DBManager().get_airport_obs_recent(icao, minutes=60)
|
||||
temps = [r.get("temp_c") for r in obs if r.get("temp_c") is not None]
|
||||
if len(temps) >= 4 and temps[-1] < temps[len(temps)//2]:
|
||||
return ("↓", "falling")
|
||||
except Exception:
|
||||
pass
|
||||
return ("→", "flat")
|
||||
|
||||
def _obs_age(obs_time_str: Optional[str]) -> Optional[int]:
|
||||
"""Compute minutes since observation time."""
|
||||
if not obs_time_str:
|
||||
return None
|
||||
try:
|
||||
# Try parsing various formats
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f",
|
||||
"%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
|
||||
try:
|
||||
dt = datetime.strptime(str(obs_time_str)[:26], fmt)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - dt).total_seconds()
|
||||
return max(0, int(age // 60))
|
||||
except ValueError:
|
||||
continue
|
||||
# Try as epoch
|
||||
ts = float(obs_time_str)
|
||||
if ts > 1_000_000_000:
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - dt).total_seconds()
|
||||
return max(0, int(age // 60))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _runway_pairs(city_weather: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Extract runway pairs from AMOS data."""
|
||||
amos = city_weather.get("amos") or {}
|
||||
rw_obs = (amos.get("runway_obs") or {}) if amos else {}
|
||||
pairs = rw_obs.get("runway_pairs") or []
|
||||
temps = rw_obs.get("temperatures") or []
|
||||
result = []
|
||||
for (r1, r2), (t, _d) in zip(pairs, temps):
|
||||
if t is not None:
|
||||
result.append({"label": f"{r1}/{r2}", "temp": round(t, 1)})
|
||||
return result
|
||||
|
||||
def _build_city_card(city: str, city_weather: Dict[str, Any], cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build a single city's card data."""
|
||||
ac = city_weather.get("airport_current") or {}
|
||||
cur = city_weather.get("current") or {}
|
||||
ct = _sf(ac.get("temp")) or _sf(cur.get("temp"))
|
||||
max_so_far = ac.get("max_so_far")
|
||||
max_temp_time = ac.get("max_temp_time")
|
||||
obs_time_str = ac.get("obs_time") or ""
|
||||
local_time = city_weather.get("local_time") or ""
|
||||
new_high = (ct is not None and max_so_far is not None and ct >= max_so_far + 0.3)
|
||||
|
||||
trend_sym, trend_css = _trend_info(cfg["icao"])
|
||||
age = _obs_age(obs_time_str)
|
||||
rw = _runway_pairs(city_weather) if cfg.get("rw") else []
|
||||
|
||||
return {
|
||||
"en_name": cfg["en_name"],
|
||||
"airport": cfg["airport"],
|
||||
"icao": cfg["icao"],
|
||||
"obs_time_str": obs_time_str or local_time,
|
||||
"local_time": local_time,
|
||||
"current_temp": ct,
|
||||
"max_so_far": _sf(max_so_far),
|
||||
"max_temp_time": max_temp_time,
|
||||
"trend_sym": trend_sym,
|
||||
"trend_css": trend_css,
|
||||
"obs_age_min": age,
|
||||
"new_high": new_high,
|
||||
"runway_pairs": rw,
|
||||
}
|
||||
|
||||
def _load_all_cities() -> List[Dict[str, Any]]:
|
||||
cards = []
|
||||
for cfg in _CITIES:
|
||||
try:
|
||||
cw = _analyze(cfg["key"])
|
||||
card = _build_city_card(cfg["key"], cw, cfg)
|
||||
cards.append(card)
|
||||
except Exception:
|
||||
logger.exception("monitor: failed to load city {}", cfg["key"])
|
||||
# Sort by temp descending, None at bottom
|
||||
cards.sort(key=lambda c: (c["current_temp"] is not None, c["current_temp"] or -999), reverse=True)
|
||||
return cards
|
||||
|
||||
# ── routes ──
|
||||
|
||||
@router.get("/monitor", response_class=HTMLResponse)
|
||||
async def monitor_page(request: Request):
|
||||
cities = _load_all_cities()
|
||||
return templates.TemplateResponse("monitor.html", {
|
||||
"request": request,
|
||||
"cities": cities,
|
||||
"full_page": True,
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%H:%M:%S UTC"),
|
||||
})
|
||||
|
||||
@router.get("/monitor/cards", response_class=HTMLResponse)
|
||||
async def monitor_cards(request: Request):
|
||||
cities = _load_all_cities()
|
||||
return templates.TemplateResponse("monitor.html", {
|
||||
"request": request,
|
||||
"cities": cities,
|
||||
"full_page": False,
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%H:%M:%S UTC"),
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Market Monitor — PolyWeather</title>
|
||||
<style>
|
||||
*{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:1500px;margin:0 auto;padding:24px 28px}
|
||||
.header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:28px;padding-bottom:14px;border-bottom:1px solid #1e2130}
|
||||
.header h1{font-size:24px;font-weight:600;color:#e8eaed}
|
||||
.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 .2s}
|
||||
.notify-btn:hover{border-color:#4a5160}
|
||||
.notify-btn.muted{opacity:.4}
|
||||
.updated{font-size:13px;color:#5a6170}
|
||||
.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
@media(max-width:1100px){.card-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:600px){.card-grid{grid-template-columns:1fr}}
|
||||
.card{background:#161822;border:1px solid #1e2130;border-radius:12px;padding:22px 26px;transition:border-color .3s,box-shadow .3s;position:relative}
|
||||
.card:hover{border-color:#2a2e40}
|
||||
.card.new-high-card{border-color:rgba(124,58,237,.3)}
|
||||
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:15px;flex-wrap:wrap}
|
||||
.city-name{color:#e0e3e8;font-weight:700}
|
||||
.airport{color:#6a7180;font-weight:400}
|
||||
.local-time{margin-left:auto;color:#4a5160;font-variant-numeric:tabular-nums;font-size:14px}
|
||||
.badge{font-size:12px;padding:2px 7px;border-radius:4px;font-weight:600}
|
||||
.badge.new-high{background:rgba(124,58,237,.18);color:#a78bfa}
|
||||
.card-temp{margin:8px 0 10px;font-weight:700;line-height:1.15}
|
||||
.temp-value{font-size:52px;color:#e8eaed;letter-spacing:-.03em}
|
||||
.temp-value.na{color:#3a4050;font-size:32px}
|
||||
.temp-value.warm{color:#f59e0b}
|
||||
.temp-value.new-high-val{color:#c084fc}
|
||||
.temp-unit{font-size:22px;color:#5a6170;margin-left:3px}
|
||||
.card-meta{display:flex;flex-direction:column;gap:5px;margin-bottom:2px}
|
||||
.meta-row{display:flex;align-items:baseline;gap:8px;font-size:14px}
|
||||
.meta-row .label{color:#4a5160}
|
||||
.meta-row .value{color:#9aa0b0;font-variant-numeric:tabular-nums}
|
||||
.meta-row .value.na{color:#3a4050}
|
||||
.meta-row .trend{margin-left:auto}
|
||||
.trend{font-size:18px;font-weight:700}
|
||||
.trend.rising{color:#34d399}
|
||||
.trend.falling{color:#60a5fa}
|
||||
.trend.flat{color:#5a6170}
|
||||
.max-time{font-size:12px;color:#4a5160;margin-left:2px}
|
||||
.obs-age{font-size:13px;color:#5a6170}
|
||||
.card-runway{margin-top:12px}
|
||||
.runway-divider{height:1px;background:#1e2130;margin-bottom:8px}
|
||||
.runway-row{display:flex;justify-content:space-between;font-size:13px;margin-bottom:2px}
|
||||
.runway-label{color:#4a5160}
|
||||
.runway-temp{color:#7a8290;font-variant-numeric:tabular-nums}
|
||||
.htmx-indicator{text-align:center;padding:14px;font-size:14px;color:#3a4050;display:none}
|
||||
.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:block}
|
||||
</style>
|
||||
<meta http-equiv="refresh" content="120">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<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">{{ generated_at }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if full_page or not full_page %}
|
||||
<div id="card-grid" class="card-grid"
|
||||
hx-get="/monitor/cards" hx-trigger="every 30s" hx-swap="outerHTML" hx-indicator="#spinner">
|
||||
{% endif %}
|
||||
<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 %}" data-new-high="{{ c.new_high | lower }}">
|
||||
<div class="card-top">
|
||||
<span class="city-name">{{ c.en_name }}</span>
|
||||
<span class="airport">/ {{ c.airport }}</span>
|
||||
<span class="local-time">{{ c.obs_time_str or c.local_time }}</span>
|
||||
{% if c.new_high %}
|
||||
<span class="badge new-high">◆新高</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-temp">
|
||||
{% if c.current_temp is not none %}
|
||||
<span class="temp-value{% if c.current_temp >= 30 %} warm{% endif %}{% if c.new_high %} new-high-val{% endif %}">{{ "%.1f"|format(c.current_temp) }}</span><span class="temp-unit">°C</span>
|
||||
{% else %}
|
||||
<span class="temp-value na">--</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<div class="meta-row">
|
||||
<span class="label">High</span>
|
||||
{% if c.max_so_far is not none %}
|
||||
<span class="value">{{ "%.1f"|format(c.max_so_far) }}°C</span>
|
||||
{% if c.max_temp_time %}
|
||||
<span class="max-time">{{ c.max_temp_time }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="value na">--</span>
|
||||
{% endif %}
|
||||
<span class="trend {{ c.trend_css }}">{{ c.trend_sym }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="label">Obs</span>
|
||||
{% if c.obs_age_min is not none %}
|
||||
<span class="obs-age">{{ c.obs_age_min }} min ago</span>
|
||||
{% else %}
|
||||
<span class="value na">--</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if c.runway_pairs %}
|
||||
<div class="card-runway">
|
||||
<div class="runway-divider"></div>
|
||||
{% for rw in c.runway_pairs %}
|
||||
<div class="runway-row">
|
||||
<span class="runway-label">{{ rw.label }}</span>
|
||||
<span class="runway-temp">{{ "%.1f"|format(rw.temp) }}°C</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="spinner" class="htmx-indicator">Refreshing…</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script>
|
||||
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(enName, temp){
|
||||
if(!NOTIFY_ENABLED || Notification.permission !== 'granted') return;
|
||||
var key = enName + '|' + temp;
|
||||
var today = new Date().toDateString();
|
||||
if(NOTIFIED_HIGHS._day !== today) NOTIFIED_HIGHS = {_day: today};
|
||||
if(NOTIFIED_HIGHS[key]) return;
|
||||
NOTIFIED_HIGHS[key] = true;
|
||||
localStorage.setItem('monitor_notified_highs', JSON.stringify(NOTIFIED_HIGHS));
|
||||
new Notification('🔴 New High — ' + enName, {
|
||||
body: temp + '°C\nNew daily high.',
|
||||
tag: key, requireInteraction: true
|
||||
});
|
||||
}
|
||||
updateNotifyIcon();
|
||||
(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 14px rgba(52,211,153,0.40)';
|
||||
requestAnimationFrame(function(){
|
||||
card.style.transition = 'box-shadow 2s ease-out';
|
||||
card.style.boxShadow = '';
|
||||
});
|
||||
}
|
||||
if(card.dataset.newHigh === 'true') fireNotification(name, tv);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user