feat: Implement initial PolyWeather dashboard with city list, detail view, map, and API endpoints.

This commit is contained in:
2569718930@qq.com
2026-03-06 09:05:40 +08:00
parent dc4167b514
commit af1d8ab4ed
10 changed files with 443 additions and 77 deletions
+1
View File
@@ -0,0 +1 @@
.vercel
+10 -3
View File
@@ -1,17 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
const API_BASE = const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000";
export async function GET() { export async function GET() {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try { try {
const res = await fetch(`${API_BASE}/api/cities`, { const res = await fetch(`${API_BASE}/api/cities`, {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
next: { revalidate: 120 }, next: { revalidate: 120 },
}); });
if (!res.ok) { if (!res.ok) {
const raw = await res.text();
return NextResponse.json( return NextResponse.json(
{ error: `Backend returned ${res.status}` }, { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 }, { status: 502 },
); );
} }
+10 -3
View File
@@ -1,12 +1,18 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
const API_BASE = const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000";
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
context: { params: Promise<{ name: string }> }, context: { params: Promise<{ name: string }> },
) { ) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { name } = await context.params; const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`; const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
@@ -17,8 +23,9 @@ export async function GET(
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) { if (!res.ok) {
const raw = await res.text();
return NextResponse.json( return NextResponse.json(
{ error: `Backend returned ${res.status}` }, { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
{ status: 502 }, { status: 502 },
); );
} }
+80 -8
View File
@@ -3,17 +3,17 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
--background: 222 47% 6%; --background: 223 53% 4%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222 47% 9%; --card: 223 46% 8%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--primary: 190 95% 52%; --primary: 190 95% 56%;
--primary-foreground: 222 47% 8%; --primary-foreground: 222 47% 8%;
--secondary: 223 36% 16%; --secondary: 224 30% 14%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
--accent: 217 33% 18%; --accent: 217 30% 18%;
--accent-foreground: 210 40% 98%; --accent-foreground: 210 40% 98%;
--border: 220 33% 20%; --border: 221 38% 22%;
} }
* { * {
@@ -21,9 +21,11 @@
} }
body { body {
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
background: background:
radial-gradient(circle at 15% 5%, rgba(6, 78, 99, 0.2), transparent 45%), radial-gradient(circle at 10% -10%, rgba(34, 211, 238, 0.2), transparent 40%),
radial-gradient(circle at 85% 85%, rgba(30, 41, 59, 0.4), transparent 42%), radial-gradient(circle at 90% 0%, rgba(59, 130, 246, 0.14), transparent 36%),
radial-gradient(circle at 80% 100%, rgba(8, 47, 73, 0.5), transparent 48%),
hsl(var(--background)); hsl(var(--background));
color: hsl(var(--foreground)); color: hsl(var(--foreground));
} }
@@ -33,3 +35,73 @@ body {
height: 100%; height: 100%;
font-family: inherit; font-family: inherit;
} }
.leaflet-control-zoom a {
background: rgba(2, 6, 23, 0.82) !important;
border-color: rgba(71, 85, 105, 0.5) !important;
color: #e2e8f0 !important;
}
.leaflet-control-zoom a:hover {
background: rgba(15, 23, 42, 0.95) !important;
}
.map-pill {
min-width: 52px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 5px 10px;
color: #f8fafc;
font-size: 11px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.04em;
text-transform: uppercase;
backdrop-filter: blur(8px);
box-shadow:
0 2px 14px rgba(2, 6, 23, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.map-pill.high {
background: linear-gradient(135deg, #ef4444, #b91c1c);
}
.map-pill.medium {
background: linear-gradient(135deg, #f59e0b, #b45309);
}
.map-pill.low {
background: linear-gradient(135deg, #10b981, #047857);
}
.map-pill.active {
transform: translateY(-2px) scale(1.05);
box-shadow:
0 8px 24px rgba(34, 211, 238, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.glass {
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.9) 0%,
rgba(2, 6, 23, 0.75) 100%
);
backdrop-filter: blur(10px);
}
.fade-up {
animation: fadeUp 450ms ease-out;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+68 -17
View File
@@ -5,27 +5,43 @@ import { CloudSun, Gauge, Loader2, RefreshCw, Thermometer } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Locale } from "@/lib/i18n";
import type { CityDetail } from "@/lib/types"; import type { CityDetail } from "@/lib/types";
type CityDetailPanelProps = { type CityDetailPanelProps = {
detail: CityDetail | null; detail: CityDetail | null;
loading: boolean; loading: boolean;
onRefresh: () => void; onRefresh: () => void;
locale: Locale;
text: {
cityDetail: string;
selectCityHint: string;
refresh: string;
currentMax: string;
cloud: string;
wind: string;
topProb: string;
noProb: string;
aiSummary: string;
noAnalysis: string;
};
}; };
export function CityDetailPanel({ export function CityDetailPanel({
detail, detail,
loading, loading,
onRefresh, onRefresh,
locale,
text,
}: CityDetailPanelProps) { }: CityDetailPanelProps) {
if (!detail) { if (!detail) {
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader> <CardHeader>
<CardTitle>City Detail</CardTitle> <CardTitle>{text.cityDetail}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm text-slate-400"> <CardContent className="text-sm text-slate-400">
Select a city from the left list or map marker. {text.selectCityHint}
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -36,12 +52,20 @@ export function CityDetailPanel({
detail.current?.max_so_far != null detail.current?.max_so_far != null
? detail.current.max_so_far ? detail.current.max_so_far
: detail.current?.temp; : detail.current?.temp;
const topProbabilities = (detail.probabilities?.distribution ?? [])
.slice(0, 3)
.map((item) => ({
value: item.value,
pct: Math.round(item.probability * 100),
}));
return ( return (
<Card className="h-full"> <Card className="glass h-full fade-up">
<CardHeader className="space-y-3"> <CardHeader className="space-y-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-xl uppercase tracking-wide text-cyan-200"> <CardTitle
className={`text-lg text-cyan-200 ${locale === "zh" ? "tracking-[0.06em]" : "uppercase tracking-[0.16em]"}`}
>
{detail.display_name} {detail.display_name}
</CardTitle> </CardTitle>
<Button <Button
@@ -55,7 +79,7 @@ export function CityDetailPanel({
) : ( ) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" /> <RefreshCw className="mr-1 h-3.5 w-3.5" />
)} )}
Refresh {text.refresh}
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -66,33 +90,33 @@ export function CityDetailPanel({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm"> <CardContent className="space-y-4 text-sm">
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3"> <div className="rounded-2xl border border-cyan-900/40 bg-gradient-to-br from-cyan-950/40 to-slate-900/80 p-4">
<div className="mb-1 flex items-center gap-2 text-slate-400"> <div className="mb-1 flex items-center gap-2 text-slate-300">
<Thermometer className="h-4 w-4" /> <Thermometer className="h-4 w-4" />
<span>Current / Max</span> <span>{text.currentMax}</span>
</div> </div>
<div className="text-2xl font-semibold text-white"> <div className="text-3xl font-semibold text-white">
{displayTemp ?? "--"} {displayTemp ?? "--"}
{symbol} {symbol}
</div> </div>
<div className="mt-1 text-xs text-slate-400"> <div className="mt-1 text-xs text-slate-300">
DEB: {detail.deb?.prediction ?? "--"} DEB: {detail.deb?.prediction ?? "--"}
{symbol} {symbol}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3"> <div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="mb-1 flex items-center gap-2 text-slate-400"> <div className="mb-1 flex items-center gap-2 text-slate-400">
<CloudSun className="h-4 w-4" /> <CloudSun className="h-4 w-4" />
<span>Cloud</span> <span>{text.cloud}</span>
</div> </div>
<p className="font-medium">{detail.current?.cloud_desc || "N/A"}</p> <p className="font-medium">{detail.current?.cloud_desc || "N/A"}</p>
</div> </div>
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3"> <div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="mb-1 flex items-center gap-2 text-slate-400"> <div className="mb-1 flex items-center gap-2 text-slate-400">
<Gauge className="h-4 w-4" /> <Gauge className="h-4 w-4" />
<span>Wind</span> <span>{text.wind}</span>
</div> </div>
<p className="font-medium"> <p className="font-medium">
{detail.current?.wind_speed_kt != null {detail.current?.wind_speed_kt != null
@@ -102,12 +126,39 @@ export function CityDetailPanel({
</div> </div>
</div> </div>
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3"> <div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<p className="mb-2 text-slate-400">AI Summary</p> <p className="mb-2 text-slate-300">{text.topProb}</p>
{topProbabilities.length === 0 ? (
<p className="text-xs text-slate-500">{text.noProb}</p>
) : (
<div className="space-y-2">
{topProbabilities.map((item) => (
<div key={item.value} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-200">
{item.value}
{symbol}
</span>
<span className="text-cyan-300">{item.pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-slate-800">
<div
className="h-full rounded-full bg-gradient-to-r from-cyan-400 to-blue-400"
style={{ width: `${Math.max(item.pct, 6)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<p className="mb-2 text-slate-400">{text.aiSummary}</p>
<div <div
className="max-h-44 overflow-y-auto leading-6 text-slate-200" className="max-h-44 overflow-y-auto leading-6 text-slate-200"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: detail.ai_analysis || "No analysis yet.", __html: detail.ai_analysis || text.noAnalysis,
}} }}
/> />
</div> </div>
+63 -14
View File
@@ -1,9 +1,10 @@
"use client"; "use client";
import { ThermometerSun } from "lucide-react"; import { AlertTriangle, GaugeCircle, ShieldCheck, ThermometerSun } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Locale } from "@/lib/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CitySummary } from "@/lib/types"; import type { CitySummary } from "@/lib/types";
@@ -11,6 +12,15 @@ type CityListProps = {
cities: CitySummary[]; cities: CitySummary[];
selectedCity: string | null; selectedCity: string | null;
onSelectCity: (cityName: string) => void; onSelectCity: (cityName: string) => void;
locale: Locale;
text: {
monitoredCities: string;
high: string;
medium: string;
low: string;
fahrenheit: string;
celsius: string;
};
}; };
function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" | "danger" { function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" | "danger" {
@@ -19,37 +29,76 @@ function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" |
return "success"; return "success";
} }
export function CityList({ cities, selectedCity, onSelectCity }: CityListProps) { export function CityList({
cities,
selectedCity,
onSelectCity,
locale,
text,
}: CityListProps) {
const high = cities.filter((c) => c.risk_level === "high").length;
const medium = cities.filter((c) => c.risk_level === "medium").length;
const low = cities.filter((c) => c.risk_level === "low").length;
return ( return (
<Card className="h-full"> <Card className="glass h-full overflow-hidden">
<CardHeader className="pb-3"> <CardHeader className="space-y-3 pb-3">
<CardTitle className="flex items-center justify-between text-sm uppercase tracking-wider text-slate-300"> <CardTitle className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-slate-300">
<span>Monitored Cities</span> <span className={locale === "zh" ? "tracking-[0.08em]" : ""}>
{text.monitoredCities}
</span>
<Badge variant="default">{cities.length}</Badge> <Badge variant="default">{cities.length}</Badge>
</CardTitle> </CardTitle>
<div className="grid grid-cols-3 gap-2 text-[11px]">
<div className="rounded-lg border border-red-900/70 bg-red-950/40 p-2 text-red-200">
<div className="mb-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
<span>{text.high}</span>
</div>
<p className="text-base font-semibold">{high}</p>
</div>
<div className="rounded-lg border border-amber-900/70 bg-amber-950/40 p-2 text-amber-200">
<div className="mb-1 flex items-center gap-1">
<GaugeCircle className="h-3 w-3" />
<span>{text.medium}</span>
</div>
<p className="text-base font-semibold">{medium}</p>
</div>
<div className="rounded-lg border border-emerald-900/70 bg-emerald-950/40 p-2 text-emerald-200">
<div className="mb-1 flex items-center gap-1">
<ShieldCheck className="h-3 w-3" />
<span>{text.low}</span>
</div>
<p className="text-base font-semibold">{low}</p>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="h-[calc(100%-64px)] overflow-y-auto"> <CardContent className="h-[calc(100%-128px)] overflow-y-auto pb-3">
<div className="space-y-2"> <div className="space-y-1.5">
{cities.map((city) => ( {cities.map((city) => (
<button <button
key={city.name} key={city.name}
onClick={() => onSelectCity(city.name)} onClick={() => onSelectCity(city.name)}
className={cn( className={cn(
"w-full rounded-lg border px-3 py-2 text-left transition-colors", "w-full rounded-xl border px-3 py-2 text-left transition-all",
selectedCity === city.name selectedCity === city.name
? "border-cyan-500 bg-cyan-950/40" ? "translate-x-1 border-cyan-400/60 bg-cyan-950/40 shadow-[inset_0_0_0_1px_rgba(34,211,238,0.28)]"
: "border-slate-800 bg-slate-900/70 hover:bg-slate-800/80", : "border-slate-800/90 bg-slate-900/60 hover:border-slate-700 hover:bg-slate-800/80",
)} )}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="truncate font-medium text-slate-100"> <div className="truncate text-sm font-semibold text-slate-100">
{city.display_name} {city.display_name}
</div> </div>
<Badge variant={riskVariant(city.risk_level)}>{city.risk_level}</Badge> <Badge variant={riskVariant(city.risk_level)}>{city.risk_level}</Badge>
</div> </div>
<div className="mt-1 flex items-center gap-1 text-xs text-slate-400"> <div className="mt-1.5 flex items-center gap-1 text-[11px] text-slate-400">
<ThermometerSun className="h-3.5 w-3.5" /> <ThermometerSun className="h-3.5 w-3.5" />
<span>{city.temp_unit === "fahrenheit" ? "Fahrenheit" : "Celsius"}</span> <span>
{city.temp_unit === "fahrenheit"
? text.fahrenheit
: text.celsius}
</span>
</div> </div>
</button> </button>
))} ))}
+12 -13
View File
@@ -11,20 +11,19 @@ type MapCanvasProps = {
onSelectCity: (cityName: string) => void; onSelectCity: (cityName: string) => void;
}; };
const tempIcon = (risk: CitySummary["risk_level"]) => function shortName(name: string) {
return name.length > 8 ? `${name.slice(0, 7)}.` : name;
}
const tempIcon = (
city: CitySummary,
selected: boolean,
) =>
L.divIcon({ L.divIcon({
className: "", className: "",
html: `<div style=" html: `<div class="map-pill ${city.risk_level} ${selected ? "active" : ""}">${shortName(city.display_name)}</div>`,
background:${risk === "high" ? "#ef4444" : risk === "medium" ? "#f59e0b" : "#10b981"}; iconSize: [78, 30],
color:#fff; iconAnchor: [39, 15],
padding:6px 10px;
border-radius:999px;
font-size:12px;
font-weight:700;
box-shadow:0 2px 10px rgba(0,0,0,.4);
">${risk.toUpperCase()}</div>`,
iconSize: [56, 28],
iconAnchor: [28, 14],
}); });
export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) { export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) {
@@ -38,7 +37,7 @@ export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps
<Marker <Marker
key={city.name} key={city.name}
position={[city.lat, city.lon]} position={[city.lat, city.lon]}
icon={tempIcon(city.risk_level)} icon={tempIcon(city, selectedCity === city.name)}
eventHandlers={{ click: () => onSelectCity(city.name) }} eventHandlers={{ click: () => onSelectCity(city.name) }}
> >
<Popup> <Popup>
+129 -17
View File
@@ -1,21 +1,32 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, Bot, Globe2 } from "lucide-react"; import {
AlertTriangle,
Bot,
Globe2,
Languages,
Radar,
Signal,
Waves,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CityDetailPanel } from "@/components/city-detail-panel"; import { CityDetailPanel } from "@/components/city-detail-panel";
import { CityList } from "@/components/city-list"; import { CityList } from "@/components/city-list";
import { MapView } from "@/components/map-view"; import { MapView } from "@/components/map-view";
import { getCities, getCityDetail } from "@/lib/api"; import { getCities, getCityDetail } from "@/lib/api";
import { copy, type Locale } from "@/lib/i18n";
import type { CityDetail, CitySummary } from "@/lib/types"; import type { CityDetail, CitySummary } from "@/lib/types";
export function PolyWeatherDashboard() { export function PolyWeatherDashboard() {
const [locale, setLocale] = useState<Locale>("zh");
const [cities, setCities] = useState<CitySummary[]>([]); const [cities, setCities] = useState<CitySummary[]>([]);
const [selectedCity, setSelectedCity] = useState<string | null>(null); const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [selectedDetail, setSelectedDetail] = useState<CityDetail | null>(null); const [selectedDetail, setSelectedDetail] = useState<CityDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const t = copy[locale];
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -64,6 +75,14 @@ export function PolyWeatherDashboard() {
); );
}, [cities]); }, [cities]);
const riskStats = useMemo(() => {
return {
high: cities.filter((c) => c.risk_level === "high").length,
medium: cities.filter((c) => c.risk_level === "medium").length,
low: cities.filter((c) => c.risk_level === "low").length,
};
}, [cities]);
async function refreshCurrentCity() { async function refreshCurrentCity() {
if (!selectedCity) return; if (!selectedCity) return;
setLoading(true); setLoading(true);
@@ -77,35 +96,94 @@ export function PolyWeatherDashboard() {
} }
} }
function localizeError(raw: string) {
if (raw.includes("POLYWEATHER_API_BASE_URL")) {
return t.backendConfigMissing;
}
if (raw.toLowerCase().includes("failed to load cities")) {
return `${t.loadCitiesFailed}: ${raw}`;
}
if (raw.toLowerCase().includes("failed to load city detail")) {
return `${t.loadCityDetailFailed}: ${raw}`;
}
return raw;
}
return ( return (
<main className="h-screen w-full p-3 md:p-4"> <main className="h-screen w-full overflow-hidden p-2 md:p-4">
<div className="grid h-full grid-cols-1 gap-3 md:grid-cols-[280px_1fr_360px]"> <div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[290px_1fr_370px]">
<section className="min-h-0"> <section className="min-h-0 lg:block">
<CityList <CityList
cities={orderedCities} cities={orderedCities}
selectedCity={selectedCity} selectedCity={selectedCity}
onSelectCity={setSelectedCity} onSelectCity={setSelectedCity}
locale={locale}
text={{
monitoredCities: t.monitoredCities,
high: t.high,
medium: t.medium,
low: t.low,
fahrenheit: t.fahrenheit,
celsius: t.celsius,
}}
/> />
</section> </section>
<section className="relative min-h-0"> <section className="relative min-h-0">
<div className="absolute left-3 right-3 top-3 z-[900] rounded-xl border border-slate-800 bg-slate-950/70 p-2 backdrop-blur md:left-4 md:right-4"> <div className="glass fade-up absolute left-2 right-2 top-2 z-[900] rounded-2xl border border-slate-700/80 p-2.5 md:left-4 md:right-4 md:top-4 md:p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Globe2 className="h-4 w-4 text-cyan-300" /> <Globe2 className="h-4 w-4 shrink-0 text-cyan-300" />
<h1 className="text-lg font-semibold uppercase tracking-wide text-cyan-100 md:text-xl"> <h1 className="shrink-0 text-base font-semibold uppercase tracking-[0.16em] text-cyan-100 md:text-lg">
PolyWeather PolyWeather
</h1> </h1>
<span className="hidden text-xs text-slate-400 md:inline"> <span className="hidden truncate text-xs text-slate-400 xl:inline">
Global Weather Risk Intelligence {t.brandSubtitle}
</span> </span>
</div> </div>
<Button size="sm" variant="secondary"> <div className="flex items-center gap-2">
<Bot className="mr-1 h-4 w-4" /> <div className="hidden items-center gap-2 rounded-full border border-slate-700 bg-slate-900/70 px-2 py-1 text-[11px] text-emerald-300 md:flex">
Technical Guide <Signal className="h-3 w-3" />
</Button> {t.live}
</div>
<Button
size="sm"
variant="secondary"
onClick={() => setLocale((v) => (v === "zh" ? "en" : "zh"))}
>
<Languages className="mr-1 h-4 w-4" />
{locale === "zh" ? "EN" : "ZH"}
</Button>
<Button size="sm" variant="secondary">
<Bot className="mr-1 h-4 w-4" />
{t.technicalGuide}
</Button>
</div>
</div> </div>
</div> </div>
<div className="absolute bottom-2 left-2 right-2 z-[900] md:bottom-4 md:left-4 md:right-auto">
<div className="glass fade-up inline-flex flex-wrap items-center gap-2 rounded-xl border border-slate-700/80 p-2">
<div className="rounded-lg border border-red-900/60 bg-red-950/50 px-2.5 py-1 text-xs text-red-200">
H {riskStats.high}
</div>
<div className="rounded-lg border border-amber-900/60 bg-amber-950/50 px-2.5 py-1 text-xs text-amber-200">
M {riskStats.medium}
</div>
<div className="rounded-lg border border-emerald-900/60 bg-emerald-950/50 px-2.5 py-1 text-xs text-emerald-200">
L {riskStats.low}
</div>
<div className="hidden items-center gap-1 rounded-lg border border-cyan-900/60 bg-cyan-950/40 px-2.5 py-1 text-xs text-cyan-200 sm:flex">
<Radar className="h-3.5 w-3.5" />
{cities.length} {t.cities}
</div>
<div className="hidden items-center gap-1 rounded-lg border border-slate-700 px-2.5 py-1 text-xs text-slate-300 sm:flex">
<Waves className="h-3.5 w-3.5" />
AI + DEB
</div>
</div>
</div>
<MapView <MapView
cities={orderedCities} cities={orderedCities}
selectedCity={selectedCity} selectedCity={selectedCity}
@@ -113,11 +191,45 @@ export function PolyWeatherDashboard() {
/> />
</section> </section>
<section className="min-h-0"> <section className="min-h-0 hidden lg:block">
<CityDetailPanel <CityDetailPanel
detail={selectedDetail} detail={selectedDetail}
loading={loading} loading={loading}
onRefresh={refreshCurrentCity} onRefresh={refreshCurrentCity}
locale={locale}
text={{
cityDetail: t.cityDetail,
selectCityHint: t.selectCityHint,
refresh: t.refresh,
currentMax: t.currentMax,
cloud: t.cloud,
wind: t.wind,
topProb: t.topProb,
noProb: t.noProb,
aiSummary: t.aiSummary,
noAnalysis: t.noAnalysis,
}}
/>
</section>
<section className="min-h-0 lg:hidden">
<CityDetailPanel
detail={selectedDetail}
loading={loading}
onRefresh={refreshCurrentCity}
locale={locale}
text={{
cityDetail: t.cityDetail,
selectCityHint: t.selectCityHint,
refresh: t.refresh,
currentMax: t.currentMax,
cloud: t.cloud,
wind: t.wind,
topProb: t.topProb,
noProb: t.noProb,
aiSummary: t.aiSummary,
noAnalysis: t.noAnalysis,
}}
/> />
</section> </section>
</div> </div>
@@ -126,7 +238,7 @@ export function PolyWeatherDashboard() {
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[1200] -translate-x-1/2 rounded-lg border border-red-900 bg-red-950/90 px-3 py-2 text-sm text-red-100"> <div className="pointer-events-none fixed bottom-4 left-1/2 z-[1200] -translate-x-1/2 rounded-lg border border-red-900 bg-red-950/90 px-3 py-2 text-sm text-red-100">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<span>{error}</span> <span>{localizeError(error)}</span>
</div> </div>
</div> </div>
) : null} ) : null}
+15 -2
View File
@@ -1,9 +1,20 @@
import type { CityDetail, CitySummary } from "@/lib/types"; import type { CityDetail, CitySummary } from "@/lib/types";
async function readError(res: Response, fallback: string) {
try {
const data = await res.json();
const msg = data?.error ? String(data.error) : fallback;
const detail = data?.detail ? ` ${String(data.detail)}` : "";
return `${msg}${detail}`.trim();
} catch {
return `${fallback}: ${res.status}`;
}
}
export async function getCities(): Promise<CitySummary[]> { export async function getCities(): Promise<CitySummary[]> {
const res = await fetch("/api/cities", { cache: "no-store" }); const res = await fetch("/api/cities", { cache: "no-store" });
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load cities: ${res.status}`); throw new Error(await readError(res, `Failed to load cities (${res.status})`));
} }
const data = await res.json(); const data = await res.json();
return data.cities ?? []; return data.cities ?? [];
@@ -21,7 +32,9 @@ export async function getCityDetail(
}, },
); );
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load city detail: ${res.status}`); throw new Error(
await readError(res, `Failed to load city detail (${res.status})`),
);
} }
return await res.json(); return await res.json();
} }
+55
View File
@@ -0,0 +1,55 @@
export type Locale = "zh" | "en";
export const copy = {
en: {
brandSubtitle: "Global Weather Risk Intelligence",
technicalGuide: "Technical Guide",
monitoredCities: "Monitored Cities",
cityDetail: "City Detail",
selectCityHint: "Select a city from the left list or map marker.",
live: "Live",
cities: "Cities",
currentMax: "Current / Max",
cloud: "Cloud",
wind: "Wind",
topProb: "Top Settlement Probabilities",
noProb: "No probability data yet.",
aiSummary: "AI Summary",
noAnalysis: "No analysis yet.",
refresh: "Refresh",
fahrenheit: "Fahrenheit",
celsius: "Celsius",
high: "High",
medium: "Med",
low: "Low",
loadCitiesFailed: "Failed to load cities",
loadCityDetailFailed: "Failed to load city detail",
backendConfigMissing:
"Server config missing: POLYWEATHER_API_BASE_URL is not set.",
},
zh: {
brandSubtitle: "全球天气风险智能分析",
technicalGuide: "技术说明",
monitoredCities: "监控城市",
cityDetail: "城市详情",
selectCityHint: "请从左侧列表或地图标记选择城市。",
live: "实时",
cities: "城市",
currentMax: "当前 / 最高",
cloud: "云量",
wind: "风速",
topProb: "结算概率Top3",
noProb: "暂无概率数据",
aiSummary: "AI 分析",
noAnalysis: "暂无分析",
refresh: "刷新",
fahrenheit: "华氏",
celsius: "摄氏",
high: "高",
medium: "中",
low: "低",
loadCitiesFailed: "城市列表加载失败",
loadCityDetailFailed: "城市详情加载失败",
backendConfigMissing: "服务端未配置 POLYWEATHER_API_BASE_URL。",
},
} as const;