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";
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 },
);
}
+10 -3
View File
@@ -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 },
);
}
+80 -8
View File
@@ -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);
}
}
+68 -17
View File
@@ -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 (
<Card className="h-full">
<CardHeader>
<CardTitle>City Detail</CardTitle>
<CardTitle>{text.cityDetail}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-slate-400">
Select a city from the left list or map marker.
{text.selectCityHint}
</CardContent>
</Card>
);
@@ -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 (
<Card className="h-full">
<Card className="glass h-full fade-up">
<CardHeader className="space-y-3">
<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}
</CardTitle>
<Button
@@ -55,7 +79,7 @@ export function CityDetailPanel({
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
Refresh
{text.refresh}
</Button>
</div>
<div className="flex items-center gap-2">
@@ -66,33 +90,33 @@ export function CityDetailPanel({
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
<div className="mb-1 flex items-center gap-2 text-slate-400">
<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-300">
<Thermometer className="h-4 w-4" />
<span>Current / Max</span>
<span>{text.currentMax}</span>
</div>
<div className="text-2xl font-semibold text-white">
<div className="text-3xl font-semibold text-white">
{displayTemp ?? "--"}
{symbol}
</div>
<div className="mt-1 text-xs text-slate-400">
<div className="mt-1 text-xs text-slate-300">
DEB: {detail.deb?.prediction ?? "--"}
{symbol}
</div>
</div>
<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">
<CloudSun className="h-4 w-4" />
<span>Cloud</span>
<span>{text.cloud}</span>
</div>
<p className="font-medium">{detail.current?.cloud_desc || "N/A"}</p>
</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">
<Gauge className="h-4 w-4" />
<span>Wind</span>
<span>{text.wind}</span>
</div>
<p className="font-medium">
{detail.current?.wind_speed_kt != null
@@ -102,12 +126,39 @@ export function CityDetailPanel({
</div>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
<p className="mb-2 text-slate-400">AI Summary</p>
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<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
className="max-h-44 overflow-y-auto leading-6 text-slate-200"
dangerouslySetInnerHTML={{
__html: detail.ai_analysis || "No analysis yet.",
__html: detail.ai_analysis || text.noAnalysis,
}}
/>
</div>
+63 -14
View File
@@ -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 (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm uppercase tracking-wider text-slate-300">
<span>Monitored Cities</span>
<Card className="glass h-full overflow-hidden">
<CardHeader className="space-y-3 pb-3">
<CardTitle className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-slate-300">
<span className={locale === "zh" ? "tracking-[0.08em]" : ""}>
{text.monitoredCities}
</span>
<Badge variant="default">{cities.length}</Badge>
</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>
<CardContent className="h-[calc(100%-64px)] overflow-y-auto">
<div className="space-y-2">
<CardContent className="h-[calc(100%-128px)] overflow-y-auto pb-3">
<div className="space-y-1.5">
{cities.map((city) => (
<button
key={city.name}
onClick={() => onSelectCity(city.name)}
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
? "border-cyan-500 bg-cyan-950/40"
: "border-slate-800 bg-slate-900/70 hover:bg-slate-800/80",
? "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/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="truncate font-medium text-slate-100">
<div className="truncate text-sm font-semibold text-slate-100">
{city.display_name}
</div>
<Badge variant={riskVariant(city.risk_level)}>{city.risk_level}</Badge>
</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" />
<span>{city.temp_unit === "fahrenheit" ? "Fahrenheit" : "Celsius"}</span>
<span>
{city.temp_unit === "fahrenheit"
? text.fahrenheit
: text.celsius}
</span>
</div>
</button>
))}
+12 -13
View File
@@ -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: `<div style="
background:${risk === "high" ? "#ef4444" : risk === "medium" ? "#f59e0b" : "#10b981"};
color:#fff;
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],
html: `<div class="map-pill ${city.risk_level} ${selected ? "active" : ""}">${shortName(city.display_name)}</div>`,
iconSize: [78, 30],
iconAnchor: [39, 15],
});
export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) {
@@ -38,7 +37,7 @@ export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps
<Marker
key={city.name}
position={[city.lat, city.lon]}
icon={tempIcon(city.risk_level)}
icon={tempIcon(city, selectedCity === city.name)}
eventHandlers={{ click: () => onSelectCity(city.name) }}
>
<Popup>
+129 -17
View File
@@ -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<Locale>("zh");
const [cities, setCities] = useState<CitySummary[]>([]);
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [selectedDetail, setSelectedDetail] = useState<CityDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<main className="h-screen w-full p-3 md:p-4">
<div className="grid h-full grid-cols-1 gap-3 md:grid-cols-[280px_1fr_360px]">
<section className="min-h-0">
<main className="h-screen w-full overflow-hidden p-2 md:p-4">
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[290px_1fr_370px]">
<section className="min-h-0 lg:block">
<CityList
cities={orderedCities}
selectedCity={selectedCity}
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 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="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe2 className="h-4 w-4 text-cyan-300" />
<h1 className="text-lg font-semibold uppercase tracking-wide text-cyan-100 md:text-xl">
<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 gap-3">
<div className="flex min-w-0 items-center gap-2">
<Globe2 className="h-4 w-4 shrink-0 text-cyan-300" />
<h1 className="shrink-0 text-base font-semibold uppercase tracking-[0.16em] text-cyan-100 md:text-lg">
PolyWeather
</h1>
<span className="hidden text-xs text-slate-400 md:inline">
Global Weather Risk Intelligence
<span className="hidden truncate text-xs text-slate-400 xl:inline">
{t.brandSubtitle}
</span>
</div>
<Button size="sm" variant="secondary">
<Bot className="mr-1 h-4 w-4" />
Technical Guide
</Button>
<div className="flex items-center gap-2">
<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">
<Signal className="h-3 w-3" />
{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 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
cities={orderedCities}
selectedCity={selectedCity}
@@ -113,11 +191,45 @@ export function PolyWeatherDashboard() {
/>
</section>
<section className="min-h-0">
<section className="min-h-0 hidden lg:block">
<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 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>
</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="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
<span>{error}</span>
<span>{localizeError(error)}</span>
</div>
</div>
) : null}
+15 -2
View File
@@ -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<CitySummary[]> {
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();
}
+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;