feat: Implement HTTP caching for API routes and add a dashboard sidebar with city grouping and state persistence.

This commit is contained in:
2569718930@qq.com
2026-03-12 09:29:29 +08:00
parent 22082b9a83
commit be651cd1d7
6 changed files with 207 additions and 13 deletions
+8 -3
View File
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export const dynamic = "force-dynamic";
export async function GET() {
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
@@ -25,7 +26,11 @@ export async function GET() {
);
}
const data = await res.json();
return NextResponse.json(data);
return buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch cities", detail: String(error) },
+14 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -16,6 +17,7 @@ export async function GET(
const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const bypassCache = forceRefresh === "true";
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
try {
@@ -31,7 +33,18 @@ export async function GET(
);
}
const data = await res.json();
return NextResponse.json(data);
if (bypassCache) {
return NextResponse.json(data, {
headers: {
"Cache-Control": "no-store",
},
});
}
return buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=20, stale-while-revalidate=60",
);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch city summary", detail: String(error) },
+8 -3
View File
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
_req: Request,
req: NextRequest,
context: { params: Promise<{ name: string }> },
) {
if (!API_BASE) {
@@ -30,7 +31,11 @@ export async function GET(
);
}
const data = await res.json();
return NextResponse.json(data);
return buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=60, stale-while-revalidate=300",
);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch history", detail: String(error) },
+58 -6
View File
@@ -8,22 +8,54 @@ import { CityListItem } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
const GROUP_STATE_STORAGE_KEY = "polyWeather_sidebar_groups_v1";
const DEFAULT_EXPANDED_GROUPS: Record<RiskGroupKey, boolean> = {
high: true,
medium: true,
low: false,
other: false,
};
function toRiskGroup(level?: string): RiskGroupKey {
if (level === "high" || level === "medium" || level === "low") return level;
return "other";
}
function normalizeExpandedGroups(
value: unknown,
): Record<RiskGroupKey, boolean> {
if (!value || typeof value !== "object") {
return DEFAULT_EXPANDED_GROUPS;
}
const candidate = value as Partial<Record<RiskGroupKey, unknown>>;
return {
high:
typeof candidate.high === "boolean"
? candidate.high
: DEFAULT_EXPANDED_GROUPS.high,
medium:
typeof candidate.medium === "boolean"
? candidate.medium
: DEFAULT_EXPANDED_GROUPS.medium,
low:
typeof candidate.low === "boolean"
? candidate.low
: DEFAULT_EXPANDED_GROUPS.low,
other:
typeof candidate.other === "boolean"
? candidate.other
: DEFAULT_EXPANDED_GROUPS.other,
};
}
export function CitySidebar() {
const store = useDashboardStore();
const { t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<Record<RiskGroupKey, boolean>>({
high: true,
medium: true,
low: false,
other: false,
});
const [expandedGroups, setExpandedGroups] = useState<
Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS);
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
const aSelected = a.name === selectedCity;
@@ -61,6 +93,26 @@ export function CitySidebar() {
);
}, [selectedCity, store.cities]);
useEffect(() => {
if (typeof window === "undefined") return;
const raw = window.localStorage.getItem(GROUP_STATE_STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw);
setExpandedGroups(normalizeExpandedGroups(parsed));
} catch {}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
GROUP_STATE_STORAGE_KEY,
JSON.stringify(expandedGroups),
);
} catch {}
}, [expandedGroups]);
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
+79
View File
@@ -163,6 +163,9 @@ function getMarketScanCacheKey(cityName: string, targetDate?: string | null) {
return `${cityName}::${normalizedDate}`;
}
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
export function DashboardStoreProvider({
children,
}: {
@@ -204,6 +207,8 @@ export function DashboardStoreProvider({
const [isGuideOpen, setIsGuideOpen] = useState(false);
const mapStopMotionRef = useRef<() => void>(() => {});
const hydratedSelectionRef = useRef(false);
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
const citySummariesRef = useRef<Record<string, CitySummary>>(
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
@@ -235,10 +240,55 @@ export function DashboardStoreProvider({
citySummariesRef.current = citySummariesByName;
}, [citySummariesByName]);
const scheduleBackgroundDetailRefresh = (
cityName: string,
cached: CityDetail,
cachedMeta?: { cachedAt: number; revision: string },
) => {
const nowTs = Date.now();
const lastTs = backgroundSummaryCheckAtRef.current[cityName] || 0;
if (nowTs - lastTs < BACKGROUND_SUMMARY_REFRESH_MS) {
return;
}
backgroundSummaryCheckAtRef.current[cityName] = nowTs;
void dashboardClient
.getCitySummary(cityName)
.then(async (summary) => {
const revision = getCityRevision(summary);
if (!revision || revision === cachedMeta?.revision) {
return;
}
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: false,
});
const detail = mergeAiAnalysisIfStable(cached, latestDetail);
setCityDetailsByName((current) => ({
...current,
[cityName]: detail,
}));
setCitySummariesByName((current) => ({
...current,
[cityName]: toCitySummary(detail),
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
}));
})
.catch(() => {});
};
const ensureCityDetail = async (cityName: string, force = false) => {
const cached = cityDetailsByName[cityName];
const cachedMeta = cityDetailMetaByName[cityName];
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
return cached;
}
@@ -387,6 +437,35 @@ export function DashboardStoreProvider({
}
};
useEffect(() => {
if (typeof window === "undefined") return;
if (selectedCity) {
window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity);
} else {
window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY);
}
}, [selectedCity]);
useEffect(() => {
if (hydratedSelectionRef.current) return;
if (!cities.length) return;
if (selectedCity) {
hydratedSelectionRef.current = true;
return;
}
if (typeof window === "undefined") return;
hydratedSelectionRef.current = true;
const stored = String(
window.localStorage.getItem(SELECTED_CITY_STORAGE_KEY) || "",
)
.trim()
.toLowerCase();
if (!stored) return;
if (!cities.some((city) => city.name === stored)) return;
void selectCity(stored);
}, [cities, selectedCity, selectCity]);
const refreshSelectedCity = async () => {
if (!selectedCity) return;
setLoadingState((current) => ({ ...current, refresh: true }));
+40
View File
@@ -0,0 +1,40 @@
import { createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
function normalizeTag(tag: string) {
return tag.trim().replace(/^W\//i, "");
}
function ifNoneMatchHit(ifNoneMatch: string | null, etag: string) {
if (!ifNoneMatch) return false;
const target = normalizeTag(etag);
return ifNoneMatch
.split(",")
.map((part) => normalizeTag(part))
.some((part) => part === "*" || part === target);
}
function buildEtag(body: string) {
const hash = createHash("sha1").update(body).digest("hex");
return `"${hash}"`;
}
export function buildCachedJsonResponse(
req: NextRequest,
payload: unknown,
cacheControl: string,
) {
const body = JSON.stringify(payload);
const etag = buildEtag(body);
const headers = new Headers({
"Cache-Control": cacheControl,
ETag: etag,
"Content-Type": "application/json; charset=utf-8",
});
if (ifNoneMatchHit(req.headers.get("if-none-match"), etag)) {
return new NextResponse(null, { status: 304, headers });
}
return new NextResponse(body, { status: 200, headers });
}