diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..e985853e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/frontend/app/api/cities/route.ts b/frontend/app/api/cities/route.ts index 6b7d3753..d8787c79 100644 --- a/frontend/app/api/cities/route.ts +++ b/frontend/app/api/cities/route.ts @@ -1,17 +1,24 @@ import { NextResponse } from "next/server"; -const API_BASE = - process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000"; +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; export async function GET() { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + try { const res = await fetch(`${API_BASE}/api/cities`, { headers: { Accept: "application/json" }, next: { revalidate: 120 }, }); if (!res.ok) { + const raw = await res.text(); return NextResponse.json( - { error: `Backend returned ${res.status}` }, + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, { status: 502 }, ); } diff --git a/frontend/app/api/city/[name]/route.ts b/frontend/app/api/city/[name]/route.ts index 438fdf80..f463496e 100644 --- a/frontend/app/api/city/[name]/route.ts +++ b/frontend/app/api/city/[name]/route.ts @@ -1,12 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; -const API_BASE = - process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000"; +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; export async function GET( req: NextRequest, 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 forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`; @@ -17,8 +23,9 @@ export async function GET( cache: "no-store", }); if (!res.ok) { + const raw = await res.text(); return NextResponse.json( - { error: `Backend returned ${res.status}` }, + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, { status: 502 }, ); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index d4d2bb61..50c243a8 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -3,17 +3,17 @@ @tailwind utilities; :root { - --background: 222 47% 6%; + --background: 223 53% 4%; --foreground: 210 40% 98%; - --card: 222 47% 9%; + --card: 223 46% 8%; --card-foreground: 210 40% 98%; - --primary: 190 95% 52%; + --primary: 190 95% 56%; --primary-foreground: 222 47% 8%; - --secondary: 223 36% 16%; + --secondary: 224 30% 14%; --secondary-foreground: 210 40% 98%; - --accent: 217 33% 18%; + --accent: 217 30% 18%; --accent-foreground: 210 40% 98%; - --border: 220 33% 20%; + --border: 221 38% 22%; } * { @@ -21,9 +21,11 @@ } body { + font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif; background: - radial-gradient(circle at 15% 5%, rgba(6, 78, 99, 0.2), transparent 45%), - radial-gradient(circle at 85% 85%, rgba(30, 41, 59, 0.4), transparent 42%), + radial-gradient(circle at 10% -10%, rgba(34, 211, 238, 0.2), transparent 40%), + 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)); color: hsl(var(--foreground)); } @@ -33,3 +35,73 @@ body { height: 100%; 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); + } +} diff --git a/frontend/components/city-detail-panel.tsx b/frontend/components/city-detail-panel.tsx index 9c6cdc01..9652d247 100644 --- a/frontend/components/city-detail-panel.tsx +++ b/frontend/components/city-detail-panel.tsx @@ -5,27 +5,43 @@ import { CloudSun, Gauge, Loader2, RefreshCw, Thermometer } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Locale } from "@/lib/i18n"; import type { CityDetail } from "@/lib/types"; type CityDetailPanelProps = { detail: CityDetail | null; loading: boolean; 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({ detail, loading, onRefresh, + locale, + text, }: CityDetailPanelProps) { if (!detail) { return ( - City Detail + {text.cityDetail} - Select a city from the left list or map marker. + {text.selectCityHint} ); @@ -36,12 +52,20 @@ export function CityDetailPanel({ detail.current?.max_so_far != null ? detail.current.max_so_far : detail.current?.temp; + const topProbabilities = (detail.probabilities?.distribution ?? []) + .slice(0, 3) + .map((item) => ({ + value: item.value, + pct: Math.round(item.probability * 100), + })); return ( - +
- + {detail.display_name}
@@ -66,33 +90,33 @@ export function CityDetailPanel({
-
-
+
+
- Current / Max + {text.currentMax}
-
+
{displayTemp ?? "--"} {symbol}
-
+
DEB: {detail.deb?.prediction ?? "--"} {symbol}
-
+
- Cloud + {text.cloud}

{detail.current?.cloud_desc || "N/A"}

-
+
- Wind + {text.wind}

{detail.current?.wind_speed_kt != null @@ -102,12 +126,39 @@ export function CityDetailPanel({

-
-

AI Summary

+
+

{text.topProb}

+ {topProbabilities.length === 0 ? ( +

{text.noProb}

+ ) : ( +
+ {topProbabilities.map((item) => ( +
+
+ + {item.value} + {symbol} + + {item.pct}% +
+
+
+
+
+ ))} +
+ )} +
+ +
+

{text.aiSummary}

diff --git a/frontend/components/city-list.tsx b/frontend/components/city-list.tsx index bdd9b4cb..d7093677 100644 --- a/frontend/components/city-list.tsx +++ b/frontend/components/city-list.tsx @@ -1,9 +1,10 @@ "use client"; -import { ThermometerSun } from "lucide-react"; +import { AlertTriangle, GaugeCircle, ShieldCheck, ThermometerSun } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Locale } from "@/lib/i18n"; import { cn } from "@/lib/utils"; import type { CitySummary } from "@/lib/types"; @@ -11,6 +12,15 @@ type CityListProps = { cities: CitySummary[]; selectedCity: string | null; 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" { @@ -19,37 +29,76 @@ function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" | 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 ( - - - - Monitored Cities + + + + + {text.monitoredCities} + {cities.length} +
+
+
+ + {text.high} +
+

{high}

+
+
+
+ + {text.medium} +
+

{medium}

+
+
+
+ + {text.low} +
+

{low}

+
+
- -
+ +
{cities.map((city) => ( ))} diff --git a/frontend/components/map-canvas.tsx b/frontend/components/map-canvas.tsx index 84dadbff..d55ef4f2 100644 --- a/frontend/components/map-canvas.tsx +++ b/frontend/components/map-canvas.tsx @@ -11,20 +11,19 @@ type MapCanvasProps = { 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({ className: "", - html: `
${risk.toUpperCase()}
`, - iconSize: [56, 28], - iconAnchor: [28, 14], + html: `
${shortName(city.display_name)}
`, + iconSize: [78, 30], + iconAnchor: [39, 15], }); export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) { @@ -38,7 +37,7 @@ export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps onSelectCity(city.name) }} > diff --git a/frontend/components/polyweather-dashboard.tsx b/frontend/components/polyweather-dashboard.tsx index 279eeb65..0a54e44d 100644 --- a/frontend/components/polyweather-dashboard.tsx +++ b/frontend/components/polyweather-dashboard.tsx @@ -1,21 +1,32 @@ "use client"; 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 { CityDetailPanel } from "@/components/city-detail-panel"; import { CityList } from "@/components/city-list"; import { MapView } from "@/components/map-view"; import { getCities, getCityDetail } from "@/lib/api"; +import { copy, type Locale } from "@/lib/i18n"; import type { CityDetail, CitySummary } from "@/lib/types"; export function PolyWeatherDashboard() { + const [locale, setLocale] = useState("zh"); const [cities, setCities] = useState([]); const [selectedCity, setSelectedCity] = useState(null); const [selectedDetail, setSelectedDetail] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const t = copy[locale]; useEffect(() => { let mounted = true; @@ -64,6 +75,14 @@ export function PolyWeatherDashboard() { ); }, [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() { if (!selectedCity) return; 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 ( -
-
-
+
+
+
-
-
-
- -

+
+
+
+ +

PolyWeather

- - Global Weather Risk Intelligence + + {t.brandSubtitle}
- +
+
+ + {t.live} +
+ + +
+ +
+
+
+ H {riskStats.high} +
+
+ M {riskStats.medium} +
+
+ L {riskStats.low} +
+
+ + {cities.length} {t.cities} +
+
+ + AI + DEB +
+
+
+

-
+
+
+ +
+
@@ -126,7 +238,7 @@ export function PolyWeatherDashboard() {
- {error} + {localizeError(error)}
) : null} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index bdfa89c2..184a478a 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,9 +1,20 @@ 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 { const res = await fetch("/api/cities", { cache: "no-store" }); 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(); return data.cities ?? []; @@ -21,7 +32,9 @@ export async function getCityDetail( }, ); 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(); } diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts new file mode 100644 index 00000000..2706ea08 --- /dev/null +++ b/frontend/lib/i18n.ts @@ -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;