diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx index 03bf94fa..44e5645a 100644 --- a/frontend/components/dashboard/DetailPanel.tsx +++ b/frontend/components/dashboard/DetailPanel.tsx @@ -131,6 +131,17 @@ export function DetailPanel() { const store = useDashboardStore(); const { locale, t } = useI18n(); const detail = store.selectedDetail; + const selectedCityItem = useMemo( + () => + store.selectedCity + ? store.cities.find((city) => city.name === store.selectedCity) || null + : null, + [store.cities, store.selectedCity], + ); + const selectedSummary = store.selectedCity + ? store.citySummariesByName[store.selectedCity] || null + : null; + const isBasicGuestPanel = !detail && Boolean(store.selectedCity && selectedCityItem && selectedSummary); const isPro = store.proAccess.subscriptionActive; const panelRef = useRef(null); const [heavyContentReady, setHeavyContentReady] = useState(false); @@ -279,7 +290,7 @@ export function DetailPanel() {
- {!detail ? ( + {!detail && !isBasicGuestPanel ? (
{store.loadingState.cityDetail @@ -289,18 +300,78 @@ export function DetailPanel() {
) : ( <> + {isBasicGuestPanel && selectedCityItem && selectedSummary ? ( + <> +
+

{t("detail.profile")}

+
+
+ {locale === "en-US" ? "City" : "城市"} + {selectedCityItem.display_name} +
+
+ {locale === "en-US" ? "Current" : "当前温度"} + + {selectedSummary.current?.temp != null + ? `${selectedSummary.current.temp}${selectedSummary.temp_symbol || "°C"}` + : t("common.na")} + +
+
+ {locale === "en-US" ? "Observation" : "观测时间"} + {selectedSummary.current?.obs_time || t("common.na")} +
+
+ {locale === "en-US" ? "DEB" : "DEB 预测"} + + {selectedSummary.deb?.prediction != null + ? `${selectedSummary.deb.prediction}${selectedSummary.temp_symbol || "°C"}` + : t("common.na")} + +
+
+ {locale === "en-US" ? "Settlement" : "结算口径"} + + {selectedSummary.current?.settlement_source_label || + selectedCityItem.settlement_source_label || + t("common.na")} + +
+
+ {t("section.airport")} + {selectedCityItem.airport || t("common.na")} +
+
+
+
+
+ + {locale === "en-US" ? "Pro required for intraday analysis and history" : "今日日内分析与历史对账需开通 Pro"} + + + {locale === "en-US" + ? "Guests can browse the city overview here. Sign in and subscribe to unlock the full intraday model, history reconciliation, and market-linked weather analysis." + : "游客可先浏览城市概览。登录并开通 Pro 后,可查看完整的今日日内分析、历史对账和市场联动天气解读。"} + +
+
+ + ) : null} + + {!isBasicGuestPanel ? ( + <>
{scenery ? ( <> {t("detail.sceneryAlt",
- {detail.display_name} + {detail?.display_name}
- {detail.display_name} + {detail?.display_name} {t("detail.sceneryTitle")} @@ -370,13 +441,15 @@ export function DetailPanel() {

{t("detail.todayMiniTrend")}

{heavyContentReady ? ( - + ) : (
{t("detail.loading")}
)}
{heavyContentReady ? : null} + + ) : null} )}
diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 6ee95312..509b8e62 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -216,6 +216,7 @@ export function DashboardStoreProvider({ const [proAccess, setProAccess] = useState( getInitialProAccessState, ); + const proAccessRef = useRef(getInitialProAccessState()); const mapStopMotionRef = useRef<() => void>(() => {}); const hydratedSelectionRef = useRef(false); @@ -254,6 +255,10 @@ export function DashboardStoreProvider({ citySummariesRef.current = citySummariesByName; }, [citySummariesByName]); + useEffect(() => { + proAccessRef.current = proAccess; + }, [proAccess]); + const scheduleBackgroundDetailRefresh = ( cityName: string, cached: CityDetail, @@ -481,6 +486,14 @@ export function DashboardStoreProvider({ setIsPanelOpen(true); setSelectedForecastDate(null); setFutureModalDate(null); + + if (proAccessRef.current.loading) { + await refreshProAccess(); + } + const access = proAccessRef.current; + if (!access.authenticated || !access.subscriptionActive) { + return; + } setLoadingState((current) => ({ ...current, cityDetail: true })); try { const detail = await ensureCityDetail(cityName); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 7ecbae88..ea63017a 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -154,6 +154,8 @@ export interface CitySummary { current?: { temp?: number | null; obs_time?: string | null; + settlement_source?: string | null; + settlement_source_label?: string | null; }; deb?: { prediction?: number | null; diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 72008a53..cd1c41dd 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -37,12 +37,19 @@ function isStaticAsset(pathname: string) { function isPublicPage(pathname: string) { return ( + pathname === "/" || + pathname.startsWith("/docs") || + pathname.startsWith("/subscription-help") || pathname === "/entitlement-required" || pathname.startsWith("/auth/login") || pathname.startsWith("/auth/callback") ); } +function isPublicApi(pathname: string) { + return pathname === "/api/cities" || /^\/api\/city\/[^/]+\/summary$/i.test(pathname); +} + function handleLegacyTokenGate(request: NextRequest) { const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim(); if (!requiredToken) { @@ -50,7 +57,7 @@ function handleLegacyTokenGate(request: NextRequest) { } const { pathname, searchParams } = request.nextUrl; - if (isStaticAsset(pathname) || isPublicPage(pathname)) { + if (isStaticAsset(pathname) || isPublicPage(pathname) || isPublicApi(pathname)) { return NextResponse.next(); } @@ -91,7 +98,7 @@ function handleLegacyTokenGate(request: NextRequest) { async function handleSupabaseAuthGate(request: NextRequest) { const { pathname } = request.nextUrl; - if (isPublicPage(pathname)) { + if (isPublicPage(pathname) || isPublicApi(pathname)) { return NextResponse.next(); } if (pathname.startsWith("/api/")) { diff --git a/web/routes.py b/web/routes.py index 84d19517..e3f28a7c 100644 --- a/web/routes.py +++ b/web/routes.py @@ -156,7 +156,6 @@ async def metrics(): @router.get("/api/cities") async def list_cities(request: Request): - _assert_entitlement(request) try: out = [] for name, info in CITIES.items(): @@ -630,7 +629,6 @@ async def payment_reconcile_latest(request: Request): @router.get("/api/city/{name}/summary") async def city_summary(request: Request, name: str, force_refresh: bool = False): - _assert_entitlement(request) data = _analyze(_normalize_city_or_404(name), force_refresh=force_refresh) return _build_city_summary_payload(data)