砍掉 /m HTML 页面,只保留 /m/json 给 React 组件用
This commit is contained in:
@@ -5,19 +5,9 @@ const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) {
|
||||
return new NextResponse("API not configured", { status: 500 });
|
||||
return NextResponse.json({ error: "API not configured" }, { status: 500 });
|
||||
}
|
||||
const isCards = req.nextUrl.searchParams.get("cards") === "1";
|
||||
const asJson = req.nextUrl.searchParams.get("json") === "1";
|
||||
const path = asJson ? "/m/json" : isCards ? "/m/cards" : "/m";
|
||||
const auth = await buildBackendRequestHeaders(req, { includeSupabaseIdentity: false });
|
||||
const res = await fetch(`${API_BASE}${path}`, { headers: auth.headers });
|
||||
if (asJson) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
const html = await res.text();
|
||||
return new NextResponse(html, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/m/json`, { headers: auth.headers });
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function MonitorPanel() {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/m?json=1");
|
||||
const res = await fetch("/api/m");
|
||||
const data = await res.json();
|
||||
setCities(data);
|
||||
setTime(new Date().toLocaleTimeString());
|
||||
|
||||
+16
-161
@@ -1,20 +1,17 @@
|
||||
"""市场监控网页版 — f-string 拼 HTML,零模板引擎依赖。"""
|
||||
"""市场监控 — JSON API for frontend React component."""
|
||||
|
||||
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 import APIRouter
|
||||
from loguru import logger
|
||||
|
||||
from web.analysis_service import _analyze
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── city config ──
|
||||
|
||||
_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},
|
||||
@@ -29,7 +26,6 @@ _CITIES: List[Dict[str, Any]] = [
|
||||
{"key": "taipei", "en_name": "Taipei", "icao": "466920", "airport": "Songshan", "tz": 8, "tz_abbr": "TST", "rw": False},
|
||||
]
|
||||
|
||||
# ── helpers ──
|
||||
|
||||
def _sf(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
@@ -39,6 +35,7 @@ def _sf(v: Any) -> Optional[float]:
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _trend_info(icao: str) -> tuple:
|
||||
try:
|
||||
from src.utils.telegram_push import _check_rising_trend
|
||||
@@ -56,6 +53,7 @@ def _trend_info(icao: str) -> tuple:
|
||||
pass
|
||||
return ("→", "flat")
|
||||
|
||||
|
||||
def _obs_age(obs_time_str: Optional[str]) -> Optional[int]:
|
||||
if not obs_time_str:
|
||||
return None
|
||||
@@ -77,7 +75,8 @@ def _obs_age(obs_time_str: Optional[str]) -> Optional[int]:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _build_cards() -> tuple:
|
||||
|
||||
def _build_cards() -> list:
|
||||
cards = []
|
||||
for cfg in _CITIES:
|
||||
try:
|
||||
@@ -105,164 +104,20 @@ def _build_cards() -> tuple:
|
||||
|
||||
cards.append({
|
||||
"en_name": cfg["en_name"], "airport": cfg["airport"],
|
||||
"time": obs or local_time,
|
||||
"ct": ct, "msf": _sf(msf), "mtt": mtt,
|
||||
"tsym": tsym, "tcss": tcss, "age": age,
|
||||
"new_high": new_high, "rw_html": rw_html,
|
||||
"warm": ct is not None and ct >= 30,
|
||||
"obs_time": obs or local_time,
|
||||
"current_temp": ct, "max_so_far": _sf(msf), "max_temp_time": mtt,
|
||||
"trend_sym": tsym, "trend_css": tcss, "obs_age_min": age,
|
||||
"new_high": new_high,
|
||||
"temp_warm": ct is not None and ct >= 30,
|
||||
"runway_pairs": rw_html,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("monitor: failed city {}", cfg["key"])
|
||||
|
||||
cards.sort(key=lambda c: (c["ct"] is not None, c["ct"] or -999), reverse=True)
|
||||
return cards, datetime.now(timezone.utc).isoformat()
|
||||
cards.sort(key=lambda c: (c["current_temp"] is not None, c["current_temp"] or -999), reverse=True)
|
||||
return cards
|
||||
|
||||
def _render(cards, gts):
|
||||
"""Build card grid HTML."""
|
||||
cards_html = []
|
||||
for c in cards:
|
||||
nc = " new-high-card" if c["new_high"] else ""
|
||||
wc = " warm" if c["warm"] else ""
|
||||
nv = " new-high-val" if c["new_high"] else ""
|
||||
nh = '◆新高' if c["new_high"] else ""
|
||||
ct_str = f'{c["ct"]:.1f}' if c["ct"] is not None else "--"
|
||||
ct_na = ' na' if c["ct"] is None else ""
|
||||
msf_str = f'{c["msf"]:.1f}°C' if c["msf"] is not None else ""
|
||||
msf_na = ' na' if c["msf"] is None else ""
|
||||
mtt_str = f'<span class="max-time">{c["mtt"]}</span>' if c["mtt"] else ""
|
||||
age_str = f'{c["age"]} min ago' if c["age"] is not None else ""
|
||||
age_na = ' na' if c["age"] is None else ""
|
||||
|
||||
cards_html.append(f'''<div class="card{nc}" data-new-high="{str(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['time']}</span>{"<span class='badge new-high'>" + nh + "</span>" if nh else ""}
|
||||
</div>
|
||||
<div class="card-temp">
|
||||
<span class="temp-value{ct_na}{wc}{nv}">{ct_str}</span><span class="temp-unit">°C</span>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="meta-row">
|
||||
<span class="label">High</span>
|
||||
<span class="value{msf_na}">{msf_str}</span>{mtt_str}
|
||||
<span class="trend {c['tcss']}">{c['tsym']}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="label">Obs</span>
|
||||
<span class="obs-age{age_na}">{age_str}</span>
|
||||
</div>
|
||||
</div>{"<div class='card-runway'><div class='runway-divider'></div>" + c['rw_html'] + "</div>" if c['rw_html'] else ""}
|
||||
</div>''')
|
||||
|
||||
return f'''<div id="card-grid" class="card-grid"
|
||||
hx-get="/m/cards" hx-trigger="every 30s" hx-swap="outerHTML" hx-indicator="#spinner">
|
||||
<span style="display:block;grid-column:1/-1;text-align:right;margin-bottom:2px;font-size:13px;color:#5a6170">{gts[11:19] if len(gts) > 19 else gts}</span>
|
||||
{chr(10).join(cards_html)}
|
||||
</div>
|
||||
<div id="spinner" class="htmx-indicator">Refreshing…</div>'''
|
||||
|
||||
|
||||
PAGE_HTML = """<!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-r{display:flex;align-items:center;gap:12px}
|
||||
.nbtn{background:none;border:1px solid #2a2e40;border-radius:6px;padding:3px 8px;font-size:16px;cursor:pointer;transition:border-color .2s}
|
||||
.nbtn:hover{border-color:#4a5160}.nbtn.muted{opacity:.4}
|
||||
.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}
|
||||
.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}
|
||||
.obs-age.na{color:#3a4050}
|
||||
.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-r">
|
||||
<button id="notify-toggle" class="nbtn" onclick="toggleNotify()" title="新高提醒">
|
||||
<span id="notify-icon">🔔</span>
|
||||
</button>
|
||||
<span class="updated" id="update-time"></span>
|
||||
</div>
|
||||
</header>
|
||||
%s
|
||||
</div>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script>
|
||||
var NF=localStorage.getItem('monitor_notify')!=='off';
|
||||
var NH=JSON.parse(localStorage.getItem('monitor_notified_highs')||'{}');
|
||||
function toggleNotify(){NF=!NF;localStorage.setItem('monitor_notify',NF?'on':'off');U();if(NF&&Notification.permission==='default')Notification.requestPermission()}
|
||||
function U(){var i=document.getElementById('notify-icon');var b=document.getElementById('notify-toggle');if(NF){i.textContent='🔔';b.classList.remove('muted')}else{i.textContent='🔕';b.classList.add('muted')}}
|
||||
function N(en,t){if(!NF||Notification.permission!=='granted')return;var k=en+'|'+t;var d=new Date().toDateString();if(NH._day!==d)NH={_day:d};if(NH[k])return;NH[k]=true;localStorage.setItem('monitor_notified_highs',JSON.stringify(NH));new Notification('🔴 New High — '+en,{body:t+'°C\\nNew daily high.',tag:k,requireInteraction:true})}
|
||||
U();
|
||||
(function(){let p={};document.body.addEventListener('htmx:afterSwap',function(e){if(e.detail.target.id!=='card-grid')return;document.querySelectorAll('.card').forEach(function(c){var n=c.querySelector('.city-name').textContent;var v=c.querySelector('.temp-value').textContent;var o=p[n];p[n]=v;if(o&&o!==v&&v!=='--'){c.style.transition='none';c.style.boxShadow='0 0 14px rgba(52,211,153,0.40)';requestAnimationFrame(function(){c.style.transition='box-shadow 2s ease-out';c.style.boxShadow=''})}if(c.dataset.newHigh==='true')N(n,v)})});var el=document.getElementById('update-time');if(el)el.textContent=new Date().toLocaleTimeString();})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/m", response_class=HTMLResponse)
|
||||
async def monitor_page(request: Request):
|
||||
cards, gts = _build_cards()
|
||||
return HTMLResponse(PAGE_HTML % _render(cards, gts))
|
||||
|
||||
@router.get("/m/cards", response_class=HTMLResponse)
|
||||
async def monitor_cards(request: Request):
|
||||
cards, gts = _build_cards()
|
||||
return HTMLResponse(_render(cards, gts))
|
||||
|
||||
@router.get("/m/json")
|
||||
async def monitor_json(request: Request):
|
||||
cards, gts = _build_cards()
|
||||
return [{
|
||||
"en_name": c["en_name"], "airport": c["airport"],
|
||||
"obs_time": c["time"], "current_temp": c["ct"],
|
||||
"max_so_far": c["msf"], "max_temp_time": c["mtt"],
|
||||
"trend_sym": c["tsym"], "trend_css": c["tcss"],
|
||||
"obs_age_min": c["age"], "new_high": c["new_high"],
|
||||
"temp_warm": c["warm"], "runway_pairs": c["rw_html"],
|
||||
} for c in cards]
|
||||
async def monitor_json():
|
||||
return _build_cards()
|
||||
|
||||
Reference in New Issue
Block a user