diff --git a/frontend/app/api/analytics/events/route.ts b/frontend/app/api/analytics/events/route.ts index 1abbcbf8..9561b0d6 100644 --- a/frontend/app/api/analytics/events/route.ts +++ b/frontend/app/api/analytics/events/route.ts @@ -26,6 +26,22 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); + const payload = + body && typeof body.payload === "object" && body.payload != null + ? body.payload + : {}; + const enrichedBody = { + ...(body ?? {}), + payload: { + ...payload, + cf_country: + req.headers.get("cf-ipcountry") || + req.headers.get("x-vercel-ip-country") || + "", + user_agent: req.headers.get("user-agent") || "", + referer_header: req.headers.get("referer") || "", + }, + }; const auth = await buildBackendRequestHeaders(req, { includeSupabaseIdentity: false, }); @@ -34,7 +50,7 @@ export async function POST(req: NextRequest) { const res = await fetch(`${API_BASE}/api/analytics/events`, { method: "POST", headers, - body: JSON.stringify(body ?? {}), + body: JSON.stringify(enrichedBody), cache: "no-store", }); if (!res.ok) { diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index 7777c399..c36dc5d6 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -27,6 +27,56 @@ import { const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +async function trackAuthDiagnosticEvent( + req: NextRequest, + { + email, + reason, + responseMode, + userId, + }: { + email: string | null; + reason: string; + responseMode: "snapshot" | "degraded" | "anonymous"; + userId?: string | null; + }, +) { + if (!API_BASE) return; + const normalizedUserId = String(userId || "").trim(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1200); + try { + await fetch(`${API_BASE}/api/analytics/events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event_type: "degraded_auth_profile", + client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined, + payload: { + route: "/api/auth/me", + reason: String(reason || "unknown").slice(0, 240), + response_mode: responseMode, + user_id: normalizedUserId || undefined, + email_domain: email?.includes("@") ? email.split("@").pop() : undefined, + cf_country: + req.headers.get("cf-ipcountry") || + req.headers.get("x-vercel-ip-country") || + "", + user_agent: req.headers.get("user-agent") || "", + referer_header: req.headers.get("referer") || "", + captured_at: new Date().toISOString(), + }, + }), + cache: "no-store", + signal: controller.signal, + }); + } catch { + // Diagnostics must never block auth/profile fallback responses. + } finally { + clearTimeout(timeoutId); + } +} + type VerifiedBearerIdentity = { email: string | null; userId: string; @@ -77,7 +127,7 @@ async function getVerifiedBearerIdentity( } } -function degradedAuthProfileResponse({ +async function degradedAuthProfileResponse({ email, reason, req, @@ -94,6 +144,12 @@ function degradedAuthProfileResponse({ readEntitlementSnapshot(req, userId), ); if (snapshotPayload) { + await trackAuthDiagnosticEvent(req, { + email: snapshotPayload.email || email, + reason, + responseMode: "snapshot", + userId, + }); const snapshotResponse = NextResponse.json({ ...snapshotPayload, email: snapshotPayload.email || email, @@ -102,6 +158,12 @@ function degradedAuthProfileResponse({ return applyAuthResponseCookies(snapshotResponse, response); } + await trackAuthDiagnosticEvent(req, { + email, + reason, + responseMode: "degraded", + userId, + }); const degraded = NextResponse.json({ authenticated: true, user_id: userId, @@ -135,13 +197,20 @@ function subscriptionRequiredAuthProfileResponse({ return applyAuthResponseCookies(inactive, response); } -function unauthenticatedAuthProfileResponse({ +async function unauthenticatedAuthProfileResponse({ reason, + req, response, }: { reason: string; + req: NextRequest; response: NextResponse | null; }) { + await trackAuthDiagnosticEvent(req, { + email: null, + reason, + responseMode: "anonymous", + }); const anonymous = NextResponse.json( { authenticated: false, @@ -409,6 +478,7 @@ export async function GET(req: NextRequest) { } return unauthenticatedAuthProfileResponse({ reason: String(error), + req, response: auth?.response || null, }); } diff --git a/frontend/app/api/ops/billing-risk/route.ts b/frontend/app/api/ops/billing-risk/route.ts new file mode 100644 index 00000000..17db64ae --- /dev/null +++ b/frontend/app/api/ops/billing-risk/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const upstream = new URL(`${API_BASE}/api/ops/billing-risk`); + req.nextUrl.searchParams.forEach((value, key) => { + upstream.searchParams.set(key, value); + }); + + const res = await fetch(upstream.toString(), { + cache: "no-store", + headers: auth.headers, + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + headers: { + "Cache-Control": "no-store", + "Content-Type": res.headers.get("content-type") || "application/json", + }, + status: res.status, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Billing risk check failed", + }); + } +} diff --git a/frontend/app/api/ops/payments/route.ts b/frontend/app/api/ops/payments/route.ts new file mode 100644 index 00000000..7f687e3f --- /dev/null +++ b/frontend/app/api/ops/payments/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const upstream = new URL(`${API_BASE}/api/ops/payments`); + req.nextUrl.searchParams.forEach((value, key) => { + upstream.searchParams.set(key, value); + }); + + const res = await fetch(upstream.toString(), { + cache: "no-store", + headers: auth.headers, + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + headers: { + "Cache-Control": "no-store", + "Content-Type": res.headers.get("content-type") || "application/json", + }, + status: res.status, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch ops payments", + }); + } +} diff --git a/frontend/app/api/ops/source-health/route.ts b/frontend/app/api/ops/source-health/route.ts new file mode 100644 index 00000000..bc0f9428 --- /dev/null +++ b/frontend/app/api/ops/source-health/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const upstream = new URL(`${API_BASE}/api/ops/source-health`); + req.nextUrl.searchParams.forEach((value, key) => { + upstream.searchParams.set(key, value); + }); + + const res = await fetch(upstream.toString(), { + cache: "no-store", + headers: auth.headers, + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + headers: { + "Cache-Control": "no-store", + "Content-Type": "application/json", + }, + status: res.status, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Source health check failed", + }); + } +} diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 93477c4f..bea1b0df 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -105,6 +105,18 @@ function getWundergroundDailyHigh(hourly: HourlyForecast) { return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null; } +function shouldFetchCityDetailForChart({ + city, + documentHidden, + isChartVisible, +}: { + city: string; + documentHidden: boolean; + isChartVisible: boolean; +}) { + return Boolean(city) && isChartVisible && !documentHidden; +} + // ── Main component ───────────────────────────────────────────────────── export function LiveTemperatureThresholdChart({ @@ -141,11 +153,18 @@ export function LiveTemperatureThresholdChart({ const [userToggledKeys, setUserToggledKeys] = useState>({}); const [liveTemp, setLiveTemp] = useState(null); const [isHourlyLoading, setIsHourlyLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [detailRetryNonce, setDetailRetryNonce] = useState(0); + const [showingStaleDetail, setShowingStaleDetail] = useState(false); const hasLoadedHourlyDetailRef = useRef(false); + const chartVisibilityRef = useRef(null); const lastPatchAtRef = useRef(Date.now()); const lastAppliedPatchRevisionRef = useRef(0); const lastProbabilityRefreshAtRef = useRef(0); const localDayRolloverFetchDateRef = useRef(""); + const [isChartVisible, setIsChartVisible] = useState( + () => typeof IntersectionObserver === "undefined", + ); const [showRunwayDetails, setShowRunwayDetails] = useState(true); const [refAreaLeft, setRefAreaLeft] = useState(null); @@ -167,6 +186,9 @@ export function LiveTemperatureThresholdChart({ setHourly(seedHourlyForecastFromRow(row)); setLiveTemp(null); setIsHourlyLoading(Boolean(city)); + setDetailError(null); + setDetailRetryNonce(0); + setShowingStaleDetail(false); hasLoadedHourlyDetailRef.current = false; lastPatchAtRef.current = Date.now(); lastAppliedPatchRevisionRef.current = 0; @@ -175,6 +197,23 @@ export function LiveTemperatureThresholdChart({ setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); }, [city]); + useEffect(() => { + const node = chartVisibilityRef.current; + if (!node || typeof IntersectionObserver === "undefined") { + setIsChartVisible(true); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsChartVisible(entry.isIntersecting || entry.intersectionRatio > 0); + }, + { root: null, rootMargin: "160px 0px", threshold: 0.01 }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, []); + useEffect(() => { setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); const id = setInterval(() => { @@ -188,30 +227,47 @@ export function LiveTemperatureThresholdChart({ setIsHourlyLoading(false); return; } + + if ( + !shouldFetchCityDetailForChart({ + city, + documentHidden: + typeof document !== "undefined" && document.visibilityState === "hidden", + isChartVisible, + }) + ) { + return; + } const cacheKey = `${city}:${targetResolution}`; - // Check in-memory cache first let cached = _hourlyCache.get(cacheKey); - if (!cached) { - // Fallback to session cache - const sessionEntry = readSessionCache(cacheKey); + if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) { + const sessionEntry = readSessionCache(cacheKey, { allowStale: true }); if (sessionEntry) { cached = sessionEntry; _hourlyCache.set(cacheKey, sessionEntry); } } + const cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY; + const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS; - if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { + if (cached) { hasLoadedHourlyDetailRef.current = true; setHourly(cached.data); + setShowingStaleDetail(!hasFreshCache); + } + + if (hasFreshCache) { setIsHourlyLoading(false); + setDetailError(null); return; } - if (!hasLoadedHourlyDetailRef.current) { + if (!cached && !hasLoadedHourlyDetailRef.current) { setHourly(seedHourlyForecastFromRow(row)); + setShowingStaleDetail(false); } - setIsHourlyLoading(!hasLoadedHourlyDetailRef.current); + setIsHourlyLoading(true); let cancelled = false; // Prioritize active slots, stagger/delay background slots to optimize load performance @@ -220,11 +276,19 @@ export function LiveTemperatureThresholdChart({ const timer = setTimeout(() => { fetchHourlyForecastForCity(city, { resolution: targetResolution }) .then((data) => { - if (cancelled || !data) return; + if (cancelled) return; + if (!data) { + setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用"); + return; + } hasLoadedHourlyDetailRef.current = true; setHourly(data); + setDetailError(null); + setShowingStaleDetail(false); + }) + .catch(() => { + if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用"); }) - .catch(() => {}) .finally(() => { if (!cancelled) setIsHourlyLoading(false); }); @@ -234,7 +298,7 @@ export function LiveTemperatureThresholdChart({ cancelled = true; clearTimeout(timer); }; - }, [city, row, isActive, slotIndex, targetResolution]); + }, [city, row, isActive, slotIndex, targetResolution, isChartVisible, detailRetryNonce, isEn]); useEffect(() => { if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return; @@ -636,6 +700,12 @@ export function LiveTemperatureThresholdChart({ })); }, [isSeriesVisible]); + const handleRetryDetail = useCallback(() => { + setDetailError(null); + setIsHourlyLoading(true); + setDetailRetryNonce((value) => value + 1); + }, []); + const panelTitle = row ? (
+
+ + )} + {shouldShowEmptyState && !shouldShowUnavailableState && (
{isEn ? "No drawable chart data yet" : "暂无可绘制图表数据"} @@ -428,6 +451,18 @@ function TemperatureChartCanvasComponent({
)}
+ {shouldShowBackgroundError && ( +
+ {showingStaleDetail ? (isEn ? "Showing cache" : "显示缓存") : (isEn ? "Update failed" : "更新失败")} + +
+ )} {shouldShowBackgroundRefresh && (
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index dec70192..6651b493 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -5,11 +5,17 @@ import { DASHBOARD_REFRESH_POLICY_SEC, } from "@/lib/refresh-policy"; import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client"; -import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; +import { + __shouldFetchCityDetailForChartForTest, + __shouldPollLiveChartForTest, +} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, + HOURLY_CACHE_TTL_MS, + __readHourlyCacheEntryForTest, __resetHourlyDetailRequestQueueForTest, __runQueuedHourlyDetailRequestForTest, + clearCityDetailCache, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; function assert(condition: unknown, message: string) { @@ -79,6 +85,30 @@ export async function runTests() { chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"), "terminal charts should render from row data immediately and dedupe concurrent city detail requests", ); + assert( + chartSource.includes("IntersectionObserver") && + chartSource.includes("shouldFetchCityDetailForChart") && + chartSource.includes("isChartVisible"), + "city detail prefetch should be gated to visible chart cards instead of every mounted slot", + ); + assert( + chartSource.includes("allowStale: true") && + chartCanvasSourceIncludes(chartSource, "数据暂不可用") && + chartCanvasSourceIncludes(chartSource, "handleRetryDetail"), + "city detail charts should show stale cache first and expose a retryable unavailable state", + ); + assert( + __shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true }) === true, + "visible chart cards should fetch city detail", + ); + assert( + __shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: false }) === false, + "offscreen chart cards should not prefetch city detail", + ); + assert( + __shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: true, isChartVisible: true }) === false, + "hidden browser tabs should not prefetch city detail", + ); __resetHourlyDetailRequestQueueForTest(); let activeRequests = 0; @@ -130,4 +160,63 @@ export async function runTests() { "city detail queue should resolve every queued request in order", ); __resetHourlyDetailRequestQueueForTest(); + + const originalWindow = (globalThis as any).window; + const originalSessionStorage = (globalThis as any).sessionStorage; + const store = new Map(); + (globalThis as any).window = {}; + (globalThis as any).sessionStorage = { + get length() { + return store.size; + }, + getItem(key: string) { + return store.get(key) ?? null; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, value); + }, + }; + try { + clearCityDetailCache(); + const cacheKey = "paris:10m"; + store.set( + `polyweather_city_detail_v1:${cacheKey}`, + JSON.stringify({ + ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000, + data: { + forecastDaily: [], + localDate: "2026-05-31", + multiModelDaily: {}, + probabilities: null, + temps: [21], + times: ["00:00"], + }, + }), + ); + assert( + __readHourlyCacheEntryForTest(cacheKey) === null, + "stale city detail cache should not suppress a fresh revalidation", + ); + assert( + __readHourlyCacheEntryForTest(cacheKey, { allowStale: true })?.data?.temps[0] === 21, + "stale city detail cache should still be available for immediate chart rendering", + ); + } finally { + clearCityDetailCache(); + (globalThis as any).window = originalWindow; + (globalThis as any).sessionStorage = originalSessionStorage; + } +} + +function chartCanvasSourceIncludes(source: string, pattern: string) { + return source.includes(pattern) || fs.readFileSync( + path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"), + "utf8", + ).includes(pattern); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index f72afb5b..a3e8e8c2 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -364,19 +364,50 @@ const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:"; const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; -function readSessionCache(city: string): { ts: number; data: HourlyForecast } | null { +type HourlyCacheEntry = { ts: number; data: HourlyForecast }; + +function isFreshHourlyCacheEntry(entry: HourlyCacheEntry | null | undefined) { + return Boolean(entry && Date.now() - Number(entry.ts || 0) < SESSION_CACHE_TTL_MS); +} + +function readSessionCache( + city: string, + options: { allowStale?: boolean } = {}, +): HourlyCacheEntry | null { if (typeof window === "undefined") return null; try { const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); if (!raw) return null; const item = JSON.parse(raw); - if (item && item.ts && Date.now() - item.ts < SESSION_CACHE_TTL_MS) { + if ( + item && + item.ts && + (options.allowStale || Date.now() - item.ts < SESSION_CACHE_TTL_MS) + ) { return item; } } catch {} return null; } +function readHourlyCacheEntry( + cacheKey: string, + options: { allowStale?: boolean } = {}, +): HourlyCacheEntry | null { + const cached = _hourlyCache.get(cacheKey); + if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached))) { + return cached; + } + + const sessionEntry = readSessionCache(cacheKey, options); + if (sessionEntry) { + _hourlyCache.set(cacheKey, sessionEntry); + return sessionEntry; + } + + return null; +} + function writeSessionCache(city: string, data: HourlyForecast) { if (typeof window === "undefined" || !data) return; try { @@ -436,6 +467,7 @@ function __resetHourlyDetailRequestQueueForTest() { } const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest; +const __readHourlyCacheEntryForTest = readHourlyCacheEntry; function validNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; @@ -969,16 +1001,10 @@ async function fetchHourlyForecastForCity( const cacheKey = `${city}:${resParam}`; if (!options.ignoreCache) { - const cached = _hourlyCache.get(cacheKey); - if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { + const cached = readHourlyCacheEntry(cacheKey); + if (cached) { return cached.data; } - - const sessionCached = readSessionCache(cacheKey); - if (sessionCached) { - _hourlyCache.set(cacheKey, sessionCached); - return sessionCached.data; - } } const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`; @@ -2259,6 +2285,7 @@ export { HOURLY_DETAIL_REQUEST_TIMEOUT_MS, HOURLY_CACHE_TTL_MS, _hourlyCache, + __readHourlyCacheEntryForTest, __resetHourlyDetailRequestQueueForTest, __runQueuedHourlyDetailRequestForTest, buildChartDomain, diff --git a/frontend/components/ops/__tests__/opsAnalyticsFunnel.test.ts b/frontend/components/ops/__tests__/opsAnalyticsFunnel.test.ts new file mode 100644 index 00000000..b700f97e --- /dev/null +++ b/frontend/components/ops/__tests__/opsAnalyticsFunnel.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8"); + const analyticsPage = fs.readFileSync( + path.join(projectRoot, "components", "ops", "analytics", "AnalyticsPageClient.tsx"), + "utf8", + ); + const overviewPage = fs.readFileSync( + path.join(projectRoot, "components", "ops", "overview", "OverviewPageClient.tsx"), + "utf8", + ); + const appAnalytics = fs.readFileSync(path.join(projectRoot, "lib", "app-analytics.ts"), "utf8"); + const analyticsRoute = fs.readFileSync( + path.join(projectRoot, "app", "api", "analytics", "events", "route.ts"), + "utf8", + ); + const authMeRoute = fs.readFileSync(path.join(projectRoot, "app", "api", "auth", "me", "route.ts"), "utf8"); + + assert( + opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') && + opsApi.includes("diagnostics?:") && + opsApi.includes("traffic?:") && + opsApi.includes("uniqueActors"), + "ops funnel API client must preserve the full standard funnel and expose diagnostics/traffic dimensions", + ); + assert( + analyticsPage.includes("落地页访问") && + analyticsPage.includes("进入终端") && + analyticsPage.includes("注册成功") && + analyticsPage.includes("鉴权降级") && + analyticsPage.includes("来源与设备") && + analyticsPage.includes("paymentSuccess?.count") && + !analyticsPage.includes("总注册") && + !analyticsPage.includes("点击高级功能"), + "ops analytics page must show the real funnel semantics instead of stale index-based labels", + ); + assert( + overviewPage.includes("stepByKey.payment_success?.count") && + !overviewPage.includes("steps[5]?.count"), + "ops overview must derive paid conversion from payment_success by key, not from a brittle funnel index", + ); + assert( + appAnalytics.includes("referrer: document.referrer") && + appAnalytics.includes("device_type: getDeviceType()") && + analyticsRoute.includes("cf-ipcountry") && + analyticsRoute.includes("user_agent"), + "client analytics events must carry source, country, and device metadata for acquisition analysis", + ); + assert( + authMeRoute.includes('event_type: "degraded_auth_profile"') && + authMeRoute.includes("response_mode") && + authMeRoute.includes("trackAuthDiagnosticEvent"), + "auth/me fallback paths must emit degraded_auth_profile diagnostics for ops monitoring", + ); +} diff --git a/frontend/components/ops/__tests__/opsBillingRisk.test.ts b/frontend/components/ops/__tests__/opsBillingRisk.test.ts new file mode 100644 index 00000000..2f68e09d --- /dev/null +++ b/frontend/components/ops/__tests__/opsBillingRisk.test.ts @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8"); + const paymentsPage = fs.readFileSync( + path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"), + "utf8", + ); + const billingRiskRoute = fs.readFileSync( + path.join(projectRoot, "app", "api", "ops", "billing-risk", "route.ts"), + "utf8", + ); + const paymentsRoute = fs.readFileSync( + path.join(projectRoot, "app", "api", "ops", "payments", "route.ts"), + "utf8", + ); + + assert( + opsApi.includes("billingRisk") && + opsApi.includes("/api/ops/billing-risk") && + opsApi.includes("/api/ops/payments"), + "ops client must expose billing risk and successful payments endpoints", + ); + assert( + paymentsPage.includes("支付与邀请风控流水") && + paymentsPage.includes("试用漏开") && + paymentsPage.includes("Intent 卡住") && + paymentsPage.includes("积分异常") && + paymentsPage.includes("推荐奖励结算") && + paymentsPage.includes("月度邀请封顶"), + "ops payment page must surface trial, stuck intent, referral, points, and monthly-cap risk signals", + ); + assert( + billingRiskRoute.includes("requireOpsProxyAuth") && + billingRiskRoute.includes("/api/ops/billing-risk") && + billingRiskRoute.includes("no-store"), + "billing risk proxy must stay ops-admin protected and uncached", + ); + assert( + paymentsRoute.includes("requireOpsProxyAuth") && + paymentsRoute.includes("/api/ops/payments") && + paymentsRoute.includes("no-store"), + "ops payments proxy must stay ops-admin protected and uncached", + ); +} diff --git a/frontend/components/ops/__tests__/opsShellStyle.test.ts b/frontend/components/ops/__tests__/opsShellStyle.test.ts new file mode 100644 index 00000000..09d2fe19 --- /dev/null +++ b/frontend/components/ops/__tests__/opsShellStyle.test.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const shell = fs.readFileSync( + path.join(projectRoot, "components", "ops", "layout", "AdminShell.tsx"), + "utf8", + ); + const sidebar = fs.readFileSync( + path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"), + "utf8", + ); + const css = fs.readFileSync( + path.join(projectRoot, "components", "ops", "layout", "AdminShell.module.css"), + "utf8", + ); + + assert( + shell.includes("AdminShell.module.css") && + shell.includes("styles.root") && + shell.includes("styles.main") && + shell.includes("styles.content"), + "ops shell must use scoped terminal-style admin theme classes", + ); + assert( + sidebar.includes("bg-white") && + sidebar.includes("bg-blue-50") && + sidebar.includes("shadow-[inset_3px_0_0_#2563eb]"), + "ops sidebar must use the light terminal navigation treatment with blue active state", + ); + assert( + css.includes("#eef2f7") && + css.includes("[class~=\"text-white\"]") && + css.includes("[class*=\"bg-[#0f172a]\"]") && + css.includes("[class~=\"rounded-3xl\"]") && + css.includes("[class*=\"border-white/\"]") && + css.includes(".recharts-cartesian-grid line"), + "ops scoped CSS must map legacy dark ops utility classes to the light terminal workbench palette", + ); +} diff --git a/frontend/components/ops/__tests__/opsSourceHealth.test.ts b/frontend/components/ops/__tests__/opsSourceHealth.test.ts new file mode 100644 index 00000000..aeb79564 --- /dev/null +++ b/frontend/components/ops/__tests__/opsSourceHealth.test.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8"); + const systemPage = fs.readFileSync( + path.join(projectRoot, "components", "ops", "system", "SystemPageClient.tsx"), + "utf8", + ); + const nextRoute = fs.readFileSync( + path.join(projectRoot, "app", "api", "ops", "source-health", "route.ts"), + "utf8", + ); + + assert( + opsApi.includes("sourceHealth") && + opsApi.includes("/api/ops/source-health"), + "ops client must expose the city source health endpoint", + ); + assert( + systemPage.includes("城市数据源健康") && + systemPage.includes("sourceHealth") && + systemPage.includes("MGM、KNMI、IMS") && + systemPage.includes("断线") && + systemPage.includes("延迟"), + "ops system page must show source latency/disconnect status for operational city sources", + ); + assert( + nextRoute.includes("requireOpsProxyAuth") && + nextRoute.includes("/api/ops/source-health") && + nextRoute.includes("no-store"), + "source health proxy must stay ops-admin protected and uncached", + ); +} diff --git a/frontend/components/ops/analytics/AnalyticsFunnelChart.tsx b/frontend/components/ops/analytics/AnalyticsFunnelChart.tsx new file mode 100644 index 00000000..e71fa428 --- /dev/null +++ b/frontend/components/ops/analytics/AnalyticsFunnelChart.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils"; + +const COLORS = ["#2563eb", "#0ea5e9", "#6366f1", "#f59e0b", "#22c55e", "#10b981", "#14b8a6"]; + +export function AnalyticsFunnelChart({ + data, +}: { + data: { name: string; count: number; pct?: number }[]; +}) { + return ( + + + + + + [`${value} 次`, "数量"]} + /> + + {data.map((_, i) => ( + + ))} + + + + ); +} diff --git a/frontend/components/ops/analytics/AnalyticsPageClient.tsx b/frontend/components/ops/analytics/AnalyticsPageClient.tsx index 1cb07267..f73ada8a 100644 --- a/frontend/components/ops/analytics/AnalyticsPageClient.tsx +++ b/frontend/components/ops/analytics/AnalyticsPageClient.tsx @@ -1,28 +1,40 @@ "use client"; import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { RefreshCcw } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { opsApi } from "@/lib/ops-api"; -import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils"; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Cell, -} from "recharts"; -type FunnelStep = { label: string; count: number; pct_of_prev?: number }; +const AnalyticsFunnelChart = dynamic( + () => import("./AnalyticsFunnelChart").then((mod) => mod.AnalyticsFunnelChart), + { + ssr: false, + loading: () =>
, + }, +); + +type FunnelStep = { + key: string; + label: string; + count: number; + uniqueActors: number; + pct_of_prev?: number; +}; +type TopItem = { name: string; count: number }; +type AuthDiagnostic = { + total?: number; + unique_actors?: number; + by_reason?: TopItem[]; +}; export function AnalyticsPageClient() { const [loading, setLoading] = useState(true); const [funnel, setFunnel] = useState([]); + const [diagnostics, setDiagnostics] = useState>({}); const [rates, setRates] = useState | null>(null); + const [traffic, setTraffic] = useState>({}); const [days, setDays] = useState(30); const load = async () => { @@ -30,7 +42,9 @@ export function AnalyticsPageClient() { try { const data = await opsApi.funnel(days); setFunnel(data.steps); + setDiagnostics(data.diagnostics ?? {}); setRates(data.rates ?? null); + setTraffic(data.traffic ?? {}); } catch { /* */ } setLoading(false); }; @@ -43,7 +57,18 @@ export function AnalyticsPageClient() { pct: s.pct_of_prev, })); - const maxCount = Math.max(...funnel.map((s) => s.count), 1); + const stepByKey = Object.fromEntries(funnel.map((step) => [step.key, step])); + const degradedAuth = diagnostics.degraded_auth_profile ?? {}; + const landingView = stepByKey.landing_view; + const terminalEntry = stepByKey.enter_terminal; + const signupSuccess = stepByKey.signup_success; + const trialCreated = stepByKey.trial_created; + const paymentStart = stepByKey.payment_start; + const paymentSuccess = stepByKey.payment_success; + const overallRate = + landingView?.uniqueActors && paymentSuccess?.uniqueActors + ? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1) + : "—"; if (loading) return
加载中...
; @@ -68,72 +93,115 @@ export function AnalyticsPageClient() {
- {/* Summary cards */} {funnel.length > 0 && ( -
+
-
总注册
-
{funnel[0]?.count ?? 0}
+
落地页访问
+
{landingView?.count ?? 0}
+
独立 {landingView?.uniqueActors ?? 0}
-
点击高级功能
-
{funnel[2]?.count ?? 0}
+
进入终端
+
{terminalEntry?.count ?? 0}
+
独立 {terminalEntry?.uniqueActors ?? 0}
+
+
+ + +
注册成功
+
{signupSuccess?.count ?? 0}
+
试用 {trialCreated?.count ?? 0}
发起支付
-
{funnel[4]?.count ?? 0}
+
{paymentStart?.count ?? 0}
支付成功
-
{funnel[5]?.count ?? 0}
-
- 总体转化 {maxCount > 0 ? `${((funnel[5]?.count ?? 0) / maxCount * 100).toFixed(1)}%` : "—"} +
{paymentSuccess?.count ?? 0}
+
+ 总体转化 {overallRate === "—" ? "—" : `${overallRate}%`}
+ + +
鉴权降级
+
{degradedAuth.total ?? 0}
+
独立 {degradedAuth.unique_actors ?? 0}
+
+
)} - {/* Funnel chart */} +
+ + 来源与设备 + +
+ {[ + ["来源", traffic.referrers ?? []], + ["国家/地区", traffic.countries ?? []], + ["设备", traffic.devices ?? []], + ["落地页路径", traffic.landing_paths ?? []], + ].map(([title, rows]) => ( +
+
{String(title)}
+ {(rows as TopItem[]).length === 0 ? ( +
暂无数据
+ ) : ( +
    + {(rows as TopItem[]).slice(0, 5).map((item) => ( +
  • + {item.name} + {item.count} +
  • + ))} +
+ )} +
+ ))} +
+
+
+ + + 鉴权降级原因 + + {(degradedAuth.by_reason ?? []).length === 0 ? ( +

暂无 degraded_auth_profile

+ ) : ( +
    + {(degradedAuth.by_reason ?? []).map((item) => ( +
  • + {item.name} + {item.count} +
  • + ))} +
+ )} +
+
+
+ - 转化漏斗 (Recharts) + 转化漏斗 {chartData.length === 0 ? ( -

暂无数据

+

暂无数据

) : ( - - - - - - [`${value} 人`, "数量"]} - /> - - {chartData.map((_, i) => { - const colors = ["#06b6d4", "#0ea5e9", "#6366f1", "#f59e0b", "#22c55e", "#10b981"]; - return ; - })} - - - + )}
- {/* Drop-off table */} 各阶段详情 @@ -141,28 +209,35 @@ export function AnalyticsPageClient() { - - - - + + + + + {funnel.map((step, i) => { const pct = step.pct_of_prev; - const dropPct = pct != null ? 100 - pct : 0; + const dropPct = pct != null ? Math.max(0, 100 - pct) : 0; return ( - - - - - + + + + + + ); })}
阶段人数转化率流失率阶段次数独立用户/访客转化率流失率
{step.label}{step.count}{pct != null ? `${pct}%` : "—"}{i > 0 ? `${dropPct}%` : "—"}
{step.label}{step.count}{step.uniqueActors}{pct != null ? `${pct}%` : "—"}{i > 0 ? `${dropPct}%` : "—"}
+ {rates ? ( +

+ rates 以 unique actors 计算,次数用于观察重复尝试和重试行为。 +

+ ) : null}
diff --git a/frontend/components/ops/health/HealthLatencyChart.tsx b/frontend/components/ops/health/HealthLatencyChart.tsx new file mode 100644 index 00000000..e61527c6 --- /dev/null +++ b/frontend/components/ops/health/HealthLatencyChart.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils"; + +const COLORS = ["#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ef4444"]; + +export function HealthLatencyChart({ + data, +}: { + data: { name: string; latency: number }[]; +}) { + return ( + + + + + + [`${value} ms`, "延迟"]} + /> + + {data.map((_, i) => ( + + ))} + + + + ); +} diff --git a/frontend/components/ops/health/HealthPageClient.tsx b/frontend/components/ops/health/HealthPageClient.tsx index caff731c..12fbe702 100644 --- a/frontend/components/ops/health/HealthPageClient.tsx +++ b/frontend/components/ops/health/HealthPageClient.tsx @@ -1,14 +1,18 @@ "use client"; import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils"; -import { - BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, - ResponsiveContainer, Cell, -} from "recharts"; + +const HealthLatencyChart = dynamic( + () => import("./HealthLatencyChart").then((mod) => mod.HealthLatencyChart), + { + ssr: false, + loading: () =>
, + }, +); type ServiceResult = { ok: boolean; @@ -107,27 +111,7 @@ export function HealthPageClient() {

服务响应延迟对比 (ms)

- - - - - - [`${value} ms`, "延迟"]} - /> - - {latencyData.map((d, i) => { - const colors = ["#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ef4444"]; - return ; - })} - - - +
)} diff --git a/frontend/components/ops/layout/AdminShell.module.css b/frontend/components/ops/layout/AdminShell.module.css new file mode 100644 index 00000000..a7a0116c --- /dev/null +++ b/frontend/components/ops/layout/AdminShell.module.css @@ -0,0 +1,182 @@ +.root { + min-height: 100vh; + background: + radial-gradient(circle at 18% 10%, rgba(37, 99, 235, 0.08), transparent 28%), + linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%); + color: var(--color-text-primary); +} + +.main { + min-height: 100vh; + margin-left: 14rem; + padding: 18px; +} + +.content { + color: var(--color-text-primary); +} + +.content :global(h1) { + color: var(--color-text-primary); + letter-spacing: 0; +} + +.content :global([class~="text-white"]), +.content :global([class~="text-slate-100"]), +.content :global([class~="text-slate-200"]), +.content :global([class~="text-slate-300"]) { + color: var(--color-text-primary); +} + +.content :global([class~="text-slate-400"]) { + color: #52637a; +} + +.content :global([class~="text-slate-500"]), +.content :global([class~="text-slate-600"]) { + color: #6b7a91; +} + +.content :global([class*="border-white/"]), +.content :global([class*="divide-white/"]), +.content :global([class*="border-black/"]) { + border-color: var(--color-border-default); +} + +.content :global([class*="border-slate-7"]), +.content :global([class*="border-slate-8"]) { + border-color: var(--color-border-default); +} + +.content :global([class*="bg-card"]), +.content :global([class*="bg-slate-900"]), +.content :global([class*="bg-slate-950"]), +.content :global([class*="bg-[#0f172a]"]), +.content :global([class*="bg-black/"]), +.content :global([class*="bg-white/5"]), +.content :global([class*="bg-white/10"]) { + background: #ffffff; +} + +.content :global([class~="rounded-2xl"]), +.content :global([class~="rounded-xl"]), +.content :global([class~="rounded-3xl"]) { + border-radius: 8px; +} + +.content :global([class*="shadow-xl"]), +.content :global([class*="shadow-2xl"]) { + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 12px 28px rgba(15, 23, 42, 0.05); +} + +.content :global([class*="backdrop-blur"]) { + backdrop-filter: none; +} + +.content :global([class*="hover:bg-white/"]:hover) { + background: #f8fafc; +} + +.content :global([class*="hover:bg-slate-"]:hover), +.content :global([class*="hover:bg-black/"]:hover) { + background: #f1f5f9; +} + +.content :global([class*="hover:text-white"]:hover), +.content :global([class*="hover:text-slate-2"]:hover), +.content :global([class*="hover:text-slate-3"]:hover) { + color: var(--color-text-primary); +} + +.content :global(code), +.content :global(pre), +.content :global(.font-mono) { + color: #1d4ed8; +} + +.content :global(input), +.content :global(select), +.content :global(textarea) { + border-color: var(--color-border-default); + background: #ffffff; + color: var(--color-text-primary); +} + +.content :global(input::placeholder), +.content :global(textarea::placeholder) { + color: #8ba0b7; +} + +.content :global(input:focus), +.content :global(select:focus), +.content :global(textarea:focus) { + border-color: #60a5fa; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); +} + +.content :global(button[class*="border"]) { + border-color: #cbd7e6; +} + +.content :global(button[class*="bg-transparent"]), +.content :global(button[class*="outline"]) { + background: #ffffff; +} + +.content :global(table) { + color: #1e293b; + border-collapse: separate; + border-spacing: 0; +} + +.content :global(thead), +.content :global(thead tr), +.content :global([class*="bg-slate-800"]) { + background: #f1f5f9; +} + +.content :global(th) { + color: #52637a; + font-weight: 800; + letter-spacing: 0.02em; +} + +.content :global(td) { + color: #1e293b; +} + +.content :global(tbody tr) { + border-color: #e5edf6; +} + +.content :global(tbody tr:hover) { + background: #f8fbff; +} + +.content :global(.recharts-cartesian-grid line) { + stroke: #dbe7f3; +} + +.content :global(.recharts-cartesian-axis-line), +.content :global(.recharts-cartesian-axis-tick-line) { + stroke: #cbd7e6; +} + +.content :global(.recharts-cartesian-axis-tick-value) { + fill: #64748b; +} + +.content :global(.recharts-legend-item-text) { + color: #475569 !important; +} + +.content :global([class*="animate-pulse"]) { + color: #64748b; +} + +@media (max-width: 900px) { + .main { + margin-left: 0; + padding: 12px; + } +} diff --git a/frontend/components/ops/layout/AdminShell.tsx b/frontend/components/ops/layout/AdminShell.tsx index 5a56b70d..6546b502 100644 --- a/frontend/components/ops/layout/AdminShell.tsx +++ b/frontend/components/ops/layout/AdminShell.tsx @@ -1,11 +1,12 @@ import { AdminSidebar } from "./AdminSidebar"; +import styles from "./AdminShell.module.css"; export function AdminShell({ children }: { children: React.ReactNode }) { return ( -
+
-
- {children} +
+
{children}
); diff --git a/frontend/components/ops/layout/AdminSidebar.tsx b/frontend/components/ops/layout/AdminSidebar.tsx index 7b53b621..70b0e4ef 100644 --- a/frontend/components/ops/layout/AdminSidebar.tsx +++ b/frontend/components/ops/layout/AdminSidebar.tsx @@ -57,17 +57,17 @@ export function AdminSidebar() { const pathname = usePathname(); return ( -