Improve ops monitoring and chart loading
This commit is contained in:
@@ -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<Record<string, boolean>>({});
|
||||
const [liveTemp, setLiveTemp] = useState<number | null>(null);
|
||||
const [isHourlyLoading, setIsHourlyLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [detailRetryNonce, setDetailRetryNonce] = useState(0);
|
||||
const [showingStaleDetail, setShowingStaleDetail] = useState(false);
|
||||
const hasLoadedHourlyDetailRef = useRef(false);
|
||||
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPatchAtRef = useRef<number>(Date.now());
|
||||
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
||||
const lastProbabilityRefreshAtRef = useRef<number>(0);
|
||||
const localDayRolloverFetchDateRef = useRef<string>("");
|
||||
const [isChartVisible, setIsChartVisible] = useState(
|
||||
() => typeof IntersectionObserver === "undefined",
|
||||
);
|
||||
|
||||
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
|
||||
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(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 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -743,7 +813,7 @@ export function LiveTemperatureThresholdChart({
|
||||
actions={timeframeActions}
|
||||
className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]}
|
||||
>
|
||||
<div className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}>
|
||||
<div ref={chartVisibilityRef} className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}>
|
||||
<TemperatureStatsBars
|
||||
isEn={isEn}
|
||||
compact={compact}
|
||||
@@ -790,6 +860,8 @@ export function LiveTemperatureThresholdChart({
|
||||
hasRunwayData={hasRunwayData}
|
||||
showRunwayDetails={showRunwayDetails}
|
||||
isHourlyLoading={isHourlyLoading}
|
||||
detailError={detailError}
|
||||
showingStaleDetail={showingStaleDetail}
|
||||
refAreaLeft={refAreaLeft}
|
||||
refAreaRight={refAreaRight}
|
||||
onMouseDown={handleMouseDown}
|
||||
@@ -799,6 +871,7 @@ export function LiveTemperatureThresholdChart({
|
||||
isSeriesVisible={isSeriesVisible}
|
||||
onSeriesToggle={handleSeriesToggle}
|
||||
onShowRunwayDetailsChange={setShowRunwayDetails}
|
||||
onRetryDetail={handleRetryDetail}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -822,6 +895,7 @@ export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||
export const __getPeakGlowStateForTest = getPeakGlowState;
|
||||
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
|
||||
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
|
||||
|
||||
@@ -110,6 +110,8 @@ function TemperatureChartCanvasComponent({
|
||||
hasRunwayData,
|
||||
showRunwayDetails,
|
||||
isHourlyLoading,
|
||||
detailError,
|
||||
showingStaleDetail,
|
||||
refAreaLeft,
|
||||
refAreaRight,
|
||||
onMouseDown,
|
||||
@@ -119,6 +121,7 @@ function TemperatureChartCanvasComponent({
|
||||
isSeriesVisible,
|
||||
onSeriesToggle,
|
||||
onShowRunwayDetailsChange,
|
||||
onRetryDetail,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
compact: boolean;
|
||||
@@ -134,6 +137,8 @@ function TemperatureChartCanvasComponent({
|
||||
hasRunwayData: boolean;
|
||||
showRunwayDetails: boolean;
|
||||
isHourlyLoading: boolean;
|
||||
detailError?: string | null;
|
||||
showingStaleDetail?: boolean;
|
||||
refAreaLeft: number | null;
|
||||
refAreaRight: number | null;
|
||||
onMouseDown: (event: any) => void;
|
||||
@@ -143,6 +148,7 @@ function TemperatureChartCanvasComponent({
|
||||
isSeriesVisible: (seriesKey: string) => boolean;
|
||||
onSeriesToggle: (seriesKey: string) => void;
|
||||
onShowRunwayDetailsChange: (value: boolean) => void;
|
||||
onRetryDetail?: () => void;
|
||||
}) {
|
||||
const chartHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
|
||||
@@ -215,6 +221,9 @@ function TemperatureChartCanvasComponent({
|
||||
const shouldRenderChart = canRenderChart && hasDrawableChartContent;
|
||||
const shouldShowEmptyState = Boolean(row?.city) && !isHourlyLoading && !hasDrawableChartContent;
|
||||
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
|
||||
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
|
||||
const shouldShowBackgroundError =
|
||||
Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
|
||||
|
||||
return (
|
||||
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
|
||||
@@ -420,7 +429,21 @@ function TemperatureChartCanvasComponent({
|
||||
))}
|
||||
</ReComposedChart>
|
||||
)}
|
||||
{shouldShowEmptyState && (
|
||||
{shouldShowUnavailableState && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center px-4 text-center">
|
||||
<div className="max-w-[260px] rounded border border-amber-200 bg-amber-50/95 px-3 py-2 text-[11px] font-semibold text-amber-700 shadow-sm">
|
||||
<div>{isEn ? "Data temporarily unavailable" : "数据暂不可用"}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryDetail}
|
||||
className="mt-2 rounded border border-amber-300 bg-white px-2 py-1 text-[10px] font-bold text-amber-700 shadow-sm transition-colors hover:bg-amber-100"
|
||||
>
|
||||
{isEn ? "Retry" : "重试"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowEmptyState && !shouldShowUnavailableState && (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center px-4 text-center">
|
||||
<div className="rounded border border-slate-200 bg-white/90 px-3 py-2 text-[11px] font-semibold text-slate-500 shadow-sm">
|
||||
{isEn ? "No drawable chart data yet" : "暂无可绘制图表数据"}
|
||||
@@ -428,6 +451,18 @@ function TemperatureChartCanvasComponent({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowBackgroundError && (
|
||||
<div className="absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-amber-200 bg-amber-50/95 px-2 py-1 text-[10px] font-semibold text-amber-700 shadow-sm">
|
||||
<span>{showingStaleDetail ? (isEn ? "Showing cache" : "显示缓存") : (isEn ? "Update failed" : "更新失败")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryDetail}
|
||||
className="rounded border border-amber-300 bg-white px-1.5 py-0.5 font-bold transition-colors hover:bg-amber-100"
|
||||
>
|
||||
{isEn ? "Retry" : "重试"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowBackgroundRefresh && (
|
||||
<div className="pointer-events-none absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-slate-200 bg-white/80 px-2 py-1 text-[10px] font-semibold text-slate-500 shadow-sm backdrop-blur-[1px]">
|
||||
<span className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-slate-200 border-t-blue-500" />
|
||||
|
||||
@@ -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<string, string>();
|
||||
(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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ResponsiveContainer width="100%" height={data.length * 60 + 40}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 40, left: 140, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#dbe7f3" />
|
||||
<XAxis type="number" stroke="#cbd7e6" tick={{ fill: "#64748b", fontSize: 12 }} />
|
||||
<YAxis type="category" dataKey="name" stroke="#cbd7e6" tick={{ fill: "#334155", fontSize: 13 }} width={130} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} 次`, "数量"]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -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: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
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<FunnelStep[]>([]);
|
||||
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
||||
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
|
||||
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 <div className="text-slate-400 animate-pulse">加载中...</div>;
|
||||
|
||||
@@ -68,72 +93,115 @@ export function AnalyticsPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{funnel.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">总注册</div>
|
||||
<div className="text-xl font-bold text-white">{funnel[0]?.count ?? 0}</div>
|
||||
<div className="text-xs text-slate-500">落地页访问</div>
|
||||
<div className="text-xl font-bold text-white">{landingView?.count ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">独立 {landingView?.uniqueActors ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">点击高级功能</div>
|
||||
<div className="text-xl font-bold text-cyan-400">{funnel[2]?.count ?? 0}</div>
|
||||
<div className="text-xs text-slate-500">进入终端</div>
|
||||
<div className="text-xl font-bold text-cyan-400">{terminalEntry?.count ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">独立 {terminalEntry?.uniqueActors ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">注册成功</div>
|
||||
<div className="text-xl font-bold text-blue-500">{signupSuccess?.count ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">试用 {trialCreated?.count ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">发起支付</div>
|
||||
<div className="text-xl font-bold text-amber-400">{funnel[4]?.count ?? 0}</div>
|
||||
<div className="text-xl font-bold text-amber-400">{paymentStart?.count ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">支付成功</div>
|
||||
<div className="text-xl font-bold text-emerald-400">{funnel[5]?.count ?? 0}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
总体转化 {maxCount > 0 ? `${((funnel[5]?.count ?? 0) / maxCount * 100).toFixed(1)}%` : "—"}
|
||||
<div className="text-xl font-bold text-emerald-400">{paymentSuccess?.count ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">
|
||||
总体转化 {overallRate === "—" ? "—" : `${overallRate}%`}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">鉴权降级</div>
|
||||
<div className="text-xl font-bold text-rose-500">{degradedAuth.total ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">独立 {degradedAuth.unique_actors ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Funnel chart */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>来源与设备</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-4">
|
||||
{[
|
||||
["来源", traffic.referrers ?? []],
|
||||
["国家/地区", traffic.countries ?? []],
|
||||
["设备", traffic.devices ?? []],
|
||||
["落地页路径", traffic.landing_paths ?? []],
|
||||
].map(([title, rows]) => (
|
||||
<div key={String(title)} className="space-y-2">
|
||||
<div className="text-xs font-bold text-slate-500">{String(title)}</div>
|
||||
{(rows as TopItem[]).length === 0 ? (
|
||||
<div className="text-xs text-slate-500">暂无数据</div>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{(rows as TopItem[]).slice(0, 5).map((item) => (
|
||||
<li key={item.name} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
|
||||
<span className="font-mono text-blue-600">{item.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>鉴权降级原因</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{(degradedAuth.by_reason ?? []).length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无 degraded_auth_profile</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{(degradedAuth.by_reason ?? []).map((item) => (
|
||||
<li key={item.name} className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
|
||||
<span className="font-mono text-rose-500">{item.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>转化漏斗 (Recharts)</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle>转化漏斗</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm py-8 text-center">暂无数据</p>
|
||||
<p className="py-8 text-center text-sm text-slate-500">暂无数据</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={chartData.length * 60 + 40}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 40, left: 140, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis type="number" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#94a3b8", fontSize: 12 }} />
|
||||
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#e2e8f0", fontSize: 13 }} width={130} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} 人`, "数量"]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
|
||||
{chartData.map((_, i) => {
|
||||
const colors = ["#06b6d4", "#0ea5e9", "#6366f1", "#f59e0b", "#22c55e", "#10b981"];
|
||||
return <Cell key={i} fill={colors[i % colors.length]} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<AnalyticsFunnelChart data={chartData} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Drop-off table */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>各阶段详情</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
@@ -141,28 +209,35 @@ export function AnalyticsPageClient() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-slate-400">
|
||||
<th className="py-2 px-3 font-medium">阶段</th>
|
||||
<th className="py-2 px-3 font-medium text-right">人数</th>
|
||||
<th className="py-2 px-3 font-medium text-right">转化率</th>
|
||||
<th className="py-2 px-3 font-medium text-right">流失率</th>
|
||||
<th className="px-3 py-2 font-medium">阶段</th>
|
||||
<th className="px-3 py-2 text-right font-medium">次数</th>
|
||||
<th className="px-3 py-2 text-right font-medium">独立用户/访客</th>
|
||||
<th className="px-3 py-2 text-right font-medium">转化率</th>
|
||||
<th className="px-3 py-2 text-right font-medium">流失率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={i} className="border-b border-white/5">
|
||||
<td className="py-2 px-3 text-white">{step.label}</td>
|
||||
<td className="py-2 px-3 text-right font-mono text-white">{step.count}</td>
|
||||
<td className="py-2 px-3 text-right text-emerald-400">{pct != null ? `${pct}%` : "—"}</td>
|
||||
<td className="py-2 px-3 text-right text-amber-400">{i > 0 ? `${dropPct}%` : "—"}</td>
|
||||
<tr key={step.key} className="border-b border-white/5">
|
||||
<td className="px-3 py-2 text-white">{step.label}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-white">{step.count}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-white">{step.uniqueActors}</td>
|
||||
<td className="px-3 py-2 text-right text-emerald-400">{pct != null ? `${pct}%` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-400">{i > 0 ? `${dropPct}%` : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rates ? (
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
rates 以 unique actors 计算,次数用于观察重复尝试和重试行为。
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 28 + 40)}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#64748b", fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#94a3b8", fontSize: 11 }} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} ms`, "延迟"]}
|
||||
/>
|
||||
<Bar dataKey="latency" radius={[0, 4, 4, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -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: () => <div className="h-[220px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
type ServiceResult = {
|
||||
ok: boolean;
|
||||
@@ -107,27 +111,7 @@ export function HealthPageClient() {
|
||||
<Card className="border-slate-800 bg-slate-900/50">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">服务响应延迟对比 (ms)</h3>
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, latencyData.length * 28 + 40)}>
|
||||
<BarChart
|
||||
data={latencyData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#64748b", fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#94a3b8", fontSize: 11 }} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} ms`, "延迟"]}
|
||||
/>
|
||||
<Bar dataKey="latency" radius={[0, 4, 4, 0]}>
|
||||
{latencyData.map((d, i) => {
|
||||
const colors = ["#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ef4444"];
|
||||
return <Cell key={i} fill={colors[i % colors.length]} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<HealthLatencyChart data={latencyData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AdminSidebar } from "./AdminSidebar";
|
||||
import styles from "./AdminShell.module.css";
|
||||
|
||||
export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
<div className={styles.root}>
|
||||
<AdminSidebar />
|
||||
<main className="ml-56 min-h-screen p-6">
|
||||
{children}
|
||||
<main className={styles.main}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,17 +57,17 @@ export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-56 border-r border-white/10 bg-slate-950 flex flex-col">
|
||||
<div className="flex h-14 items-center gap-2 border-b border-white/10 px-4">
|
||||
<LayoutDashboard className="h-5 w-5 text-cyan-400" />
|
||||
<Link href="/ops/system" className="text-sm font-bold text-white">
|
||||
<aside className="fixed left-0 top-0 z-40 flex h-screen w-56 flex-col border-r border-slate-200 bg-white shadow-[1px_0_2px_rgba(15,23,42,0.04)]">
|
||||
<div className="flex h-14 items-center gap-2 border-b border-slate-200 px-4">
|
||||
<LayoutDashboard className="h-5 w-5 text-blue-600" />
|
||||
<Link href="/ops/system" className="text-sm font-extrabold text-slate-950">
|
||||
PolyWeather Ops
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-4 space-y-6">
|
||||
<nav className="flex-1 space-y-6 overflow-y-auto py-4">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label} className="px-3">
|
||||
<h4 className="mb-2 px-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500">
|
||||
<h4 className="mb-2 px-2 text-[11px] font-bold uppercase tracking-wider text-slate-500">
|
||||
{group.label}
|
||||
</h4>
|
||||
<ul className="space-y-0.5">
|
||||
@@ -78,10 +78,10 @@ export function AdminSidebar() {
|
||||
<Link
|
||||
href={item.href}
|
||||
className={
|
||||
"flex items-center gap-2.5 rounded-lg px-2 py-2 text-sm transition-colors " +
|
||||
"flex items-center gap-2.5 rounded-md border border-transparent px-2 py-2 text-sm transition-colors " +
|
||||
(isActive
|
||||
? "bg-white/10 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-200")
|
||||
? "border-blue-200 bg-blue-50 text-blue-700 font-semibold shadow-[inset_3px_0_0_#2563eb]"
|
||||
: "text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950")
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
@@ -94,10 +94,10 @@ export function AdminSidebar() {
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-white/10 px-4 py-3">
|
||||
<div className="border-t border-slate-200 px-4 py-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
className="flex items-center gap-2 text-xs font-semibold text-slate-500 transition-colors hover:text-blue-700"
|
||||
>
|
||||
← 返回前台
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
|
||||
|
||||
export function MembershipGrowthChart({
|
||||
growth,
|
||||
}: {
|
||||
growth: GrowthPoint[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs text-slate-500 mb-2">累计会员</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">每日新增</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
|
||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCcw, X, Search } 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 type { MembershipEntry } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Legend, Area, AreaChart,
|
||||
} from "recharts";
|
||||
|
||||
const MembershipGrowthChart = dynamic(
|
||||
() => import("./MembershipGrowthChart").then((mod) => mod.MembershipGrowthChart),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
|
||||
|
||||
@@ -160,35 +164,7 @@ export function MembershipsPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cumulative area chart */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs text-slate-500 mb-2">累计会员</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Daily bars */}
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">每日新增</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
|
||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<MembershipGrowthChart growth={growth} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
type GrowthRow = { date: string; trial: number; paid: number; total: number; cumulative: number };
|
||||
type StepRow = { key?: string; label: string; count: number; pct_of_prev?: number; uniqueActors?: number };
|
||||
type BucketRow = { name: string; value: number };
|
||||
type PieRow = { name: string; value: number; color?: string };
|
||||
type CacheAnalysis = {
|
||||
total_requests?: number;
|
||||
cache_hits?: number;
|
||||
cache_misses?: number;
|
||||
force_refresh_requests?: number;
|
||||
hit_rate?: number | null;
|
||||
};
|
||||
|
||||
export function OverviewCharts({
|
||||
growth,
|
||||
steps,
|
||||
cacheBuckets,
|
||||
memberPie,
|
||||
planBreakdown,
|
||||
cachePie,
|
||||
cacheAnalysis,
|
||||
}: {
|
||||
growth: GrowthRow[];
|
||||
steps: StepRow[];
|
||||
cacheBuckets: BucketRow[];
|
||||
memberPie: PieRow[];
|
||||
planBreakdown: PieRow[];
|
||||
cachePie: PieRow[];
|
||||
cacheAnalysis?: CacheAnalysis;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{growth.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">会员增长趋势 — 近 30 天</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">累计会员</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<defs>
|
||||
<linearGradient id="colorCumulative" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.2}/>
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={35} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fillOpacity={1} fill="url(#colorCumulative)" name="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">每日新增</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={25} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" name="付费" />
|
||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b40" name="体验" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div className="space-y-5">
|
||||
{steps.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">30天转化漏斗</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={[...steps].reverse().map((s) => ({ name: s.label, count: s.count }))} layout="vertical" margin={{ left: 80, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={75} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#06b6d4" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{cacheBuckets.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">缓存桶分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={cacheBuckets} layout="vertical" margin={{ left: 50, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={45} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} fill="#6366f1" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">会员分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-36 h-36 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value">
|
||||
{memberPie.map((item, i) => (<Cell key={i} fill={item.color || "#64748b"} />))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5 text-sm">
|
||||
{memberPie.map((item) => (
|
||||
<div key={item.name} className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-slate-400">{item.name}</span>
|
||||
<span className="text-white font-bold ml-auto">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{planBreakdown.length > 0 && (
|
||||
<div className="pt-2 mt-2 border-t border-white/10 space-y-1">
|
||||
{planBreakdown.map((item) => (
|
||||
<div key={item.name} className="flex justify-between text-xs">
|
||||
<span className="text-slate-500">{item.name}</span>
|
||||
<span className="text-slate-300">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{cachePie.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
缓存分析{" "}
|
||||
{cacheAnalysis?.hit_rate != null && (
|
||||
<span className="text-emerald-400 font-normal">{(cacheAnalysis.hit_rate * 100).toFixed(1)}% 命中</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-36 h-36 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={cachePie} cx="50%" cy="50%" innerRadius={36} outerRadius={58} paddingAngle={2} dataKey="value">
|
||||
{cachePie.map((item, i) => (<Cell key={i} fill={item.color || "#64748b"} />))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">总请求</div>
|
||||
<div className="text-white font-bold">{cacheAnalysis?.total_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">强制刷新</div>
|
||||
<div className="text-blue-400 font-bold">{cacheAnalysis?.force_refresh_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">命中</div>
|
||||
<div className="text-emerald-400 font-bold">{cacheAnalysis?.cache_hits ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">未命中</div>
|
||||
<div className="text-amber-400 font-bold">{cacheAnalysis?.cache_misses ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw, TrendingUp, Users, CreditCard, Database, Activity, Cpu } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Activity, Cpu, CreditCard, Database, RefreshCcw, TrendingUp, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { SystemStatusPayload, MembershipsPayload, MembershipEntry } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
AreaChart, Area, Legend,
|
||||
} from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { MembershipEntry, MembershipsPayload, SystemStatusPayload } from "@/types/ops";
|
||||
|
||||
const OverviewCharts = dynamic(
|
||||
() => import("./OverviewCharts").then((mod) => mod.OverviewCharts),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
function KpiCard({ href, icon: Icon, label, value, color, sub }: {
|
||||
href: string; icon: React.ElementType; label: string; value: string | number; color: string; sub?: string;
|
||||
@@ -38,7 +41,7 @@ export function OverviewPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
|
||||
const [funnel, setFunnel] = useState<{ steps: { label: string; count: number; pct_of_prev?: number }[] } | null>(null);
|
||||
const [funnel, setFunnel] = useState<{ steps: { key?: string; label: string; count: number; pct_of_prev?: number; uniqueActors?: number }[] } | null>(null);
|
||||
const [growth, setGrowth] = useState<{ date: string; trial: number; paid: number; total: number; cumulative: number }[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
@@ -70,19 +73,20 @@ export function OverviewPageClient() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Derived data ──
|
||||
const paid = memberships.filter((m) => !m.is_trial).length;
|
||||
const trials = memberships.filter((m) => m.is_trial).length;
|
||||
const steps = funnel?.steps ?? [];
|
||||
const totalUsers = steps[0]?.count ?? 0;
|
||||
const payingUsers = steps[5]?.count ?? 0;
|
||||
const stepByKey = Object.fromEntries(steps.map((step) => [step.key || step.label, step]));
|
||||
const payingUsers = stepByKey.payment_success?.count ?? 0;
|
||||
const convRate = totalUsers > 0 ? ((payingUsers / totalUsers) * 100).toFixed(1) : "—";
|
||||
const cache = status?.cache;
|
||||
const cacheAnalysis = cache?.analysis;
|
||||
const td = status?.training_data;
|
||||
const features = status?.features;
|
||||
const coverage = td?.city_coverage;
|
||||
const truthRows = td?.truth_records?.row_count ?? 0;
|
||||
|
||||
// Cache bucket data for bar chart
|
||||
const cacheBuckets = cache ? [
|
||||
{ name: "API", value: cache.api_cache_entries ?? 0 },
|
||||
{ name: "预报", value: cache.open_meteo_forecast_entries ?? 0 },
|
||||
@@ -91,20 +95,17 @@ export function OverviewPageClient() {
|
||||
{ name: "结算", value: cache.settlement_entries ?? 0 },
|
||||
].filter((d) => d.value > 0) : [];
|
||||
|
||||
// Cache pie
|
||||
const cachePie = cacheAnalysis ? [
|
||||
{ name: "命中", value: cacheAnalysis.cache_hits ?? 0, color: "#22c55e" },
|
||||
{ name: "未命中", value: cacheAnalysis.cache_misses ?? 0, color: "#f59e0b" },
|
||||
{ name: "强制刷新", value: cacheAnalysis.force_refresh_requests ?? 0, color: "#3b82f6" },
|
||||
] : [];
|
||||
|
||||
// Membership pie
|
||||
const memberPie = [
|
||||
{ name: "付费", value: paid, color: "#22c55e" },
|
||||
...(trials > 0 ? [{ name: "体验", value: trials, color: "#f59e0b" }] : []),
|
||||
];
|
||||
|
||||
// Plan breakdown
|
||||
const planCounts: Record<string, number> = {};
|
||||
memberships.forEach((m) => {
|
||||
const code = m.plan_code ?? "unknown";
|
||||
@@ -117,9 +118,6 @@ export function OverviewPageClient() {
|
||||
})
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const truthRows = td?.truth_records?.row_count ?? 0;
|
||||
const coverage = td?.city_coverage;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -132,7 +130,6 @@ export function OverviewPageClient() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||
<KpiCard href="/ops/system" icon={Activity} label="系统" value={status?.db?.ok ? "OK" : "FAIL"} color={status?.db?.ok ? "text-emerald-400" : "text-red-400"} />
|
||||
<KpiCard href="/ops/memberships" icon={Users} label="付费会员" value={paid} color="text-cyan-400" sub={trials > 0 ? `+${trials} 体验` : undefined} />
|
||||
@@ -142,237 +139,61 @@ export function OverviewPageClient() {
|
||||
<KpiCard href="/ops/system" icon={Cpu} label="概率引擎" value={status?.probability?.engine_mode ?? "—"} color="text-amber-400" />
|
||||
</div>
|
||||
|
||||
{/* Membership Growth Trend */}
|
||||
{growth.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">会员增长趋势 — 近 30 天</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Cumulative area chart */}
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">累计会员</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<defs>
|
||||
<linearGradient id="colorCumulative" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.2}/>
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={35} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fillOpacity={1} fill="url(#colorCumulative)" name="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<OverviewCharts
|
||||
growth={growth}
|
||||
steps={steps}
|
||||
cacheBuckets={cacheBuckets}
|
||||
memberPie={memberPie}
|
||||
planBreakdown={planBreakdown}
|
||||
cachePie={cachePie}
|
||||
cacheAnalysis={cacheAnalysis}
|
||||
/>
|
||||
|
||||
{/* Daily stack chart */}
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">每日新增</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={25} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" name="付费" />
|
||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b40" name="体验" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Main grid: 2 columns */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
|
||||
{/* Column 1 */}
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Funnel mini chart */}
|
||||
{steps.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">30天转化漏斗</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={[...steps].reverse().map((s) => ({ name: s.label, count: s.count }))} layout="vertical" margin={{ left: 80, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={75} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#06b6d4" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cache buckets */}
|
||||
{cacheBuckets.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">缓存桶分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={cacheBuckets} layout="vertical" margin={{ left: 50, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={45} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} fill="#6366f1" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
{features && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">功能开关</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(features).map(([k, v]) => (
|
||||
<Badge key={k} variant={v ? "default" : "secondary"} className="text-[11px]">
|
||||
{k.replace(/_/g, " ")}: {String(v)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 2 */}
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Membership donut + plan breakdown side by side */}
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
{features && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">会员分布</CardTitle>
|
||||
<CardTitle className="text-sm">功能开关</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-36 h-36 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value">
|
||||
{memberPie.map((d, i) => (<Cell key={i} fill={d.color} />))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(features).map(([k, v]) => (
|
||||
<Badge key={k} variant={v ? "default" : "secondary"} className="text-[11px]">
|
||||
{k.replace(/_/g, " ")}: {String(v)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{coverage && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">城市模型覆盖</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-cyan-400">{coverage.total_cities ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">总城市</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5 text-sm">
|
||||
{memberPie.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} />
|
||||
<span className="text-slate-400">{d.name}</span>
|
||||
<span className="text-white font-bold ml-auto">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{planBreakdown.length > 0 && (
|
||||
<div className="pt-2 mt-2 border-t border-white/10 space-y-1">
|
||||
{planBreakdown.map((p) => (
|
||||
<div key={p.name} className="flex justify-between text-xs">
|
||||
<span className="text-slate-500">{p.name}</span>
|
||||
<span className="text-slate-300">{p.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-emerald-400">{coverage.with_truth_rows ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">有真值</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-purple-400">{coverage.with_feature_rows ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">有特征</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-amber-400">{td?.truth_records?.row_count ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">真值行数</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cache hit rate donut */}
|
||||
{cachePie.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
缓存分析{" "}
|
||||
{cacheAnalysis?.hit_rate != null && (
|
||||
<span className="text-emerald-400 font-normal">{(cacheAnalysis.hit_rate * 100).toFixed(1)}% 命中</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-36 h-36 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={cachePie} cx="50%" cy="50%" innerRadius={36} outerRadius={58} paddingAngle={2} dataKey="value">
|
||||
{cachePie.map((d, i) => (<Cell key={i} fill={d.color} />))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">总请求</div>
|
||||
<div className="text-white font-bold">{cacheAnalysis?.total_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">强制刷新</div>
|
||||
<div className="text-blue-400 font-bold">{cacheAnalysis?.force_refresh_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">命中</div>
|
||||
<div className="text-emerald-400 font-bold">{cacheAnalysis?.cache_hits ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">未命中</div>
|
||||
<div className="text-amber-400 font-bold">{cacheAnalysis?.cache_misses ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Training data summary */}
|
||||
{coverage && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">城市模型覆盖</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-cyan-400">{coverage.total_cities ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">总城市</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-emerald-400">{coverage.with_truth_rows ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">有真值</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-purple-400">{coverage.with_feature_rows ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">有特征</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-amber-400">{td?.truth_records?.row_count ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">真值行数</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
|
||||
|
||||
export function PaymentIncidentPieChart({
|
||||
data,
|
||||
}: {
|
||||
data: { name: string; value: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="w-[100px] h-[100px] shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={24}
|
||||
outerRadius={40}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ ...CHART_TOOLTIP_STYLE, fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-y-auto max-h-[100px] text-xs">
|
||||
{data.map((item, i) => (
|
||||
<div key={item.name} className="flex items-center gap-1.5 truncate">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: COLORS[i % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-slate-400 truncate" title={item.name}>{item.name}</span>
|
||||
<span className="text-white font-bold ml-auto">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw, CheckCircle2, ExternalLink } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCcw, CheckCircle2, ExternalLink, AlertTriangle, ShieldCheck } 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 type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
} from "recharts";
|
||||
import type {
|
||||
BillingRiskIssue,
|
||||
BillingRiskPayload,
|
||||
PaymentRuntimePayload,
|
||||
PaymentIncident,
|
||||
PaymentRecord,
|
||||
} from "@/types/ops";
|
||||
|
||||
const PaymentIncidentPieChart = dynamic(
|
||||
() => import("./PaymentIncidentPieChart").then((mod) => mod.PaymentIncidentPieChart),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <span className="text-sm text-slate-500">加载图表...</span>,
|
||||
},
|
||||
);
|
||||
|
||||
function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
const txHash = String(payment.tx_hash || "").trim();
|
||||
if (!txHash) return "";
|
||||
const chain = String(payment.chain || "").trim().toLowerCase();
|
||||
const base = chain.includes("eth")
|
||||
? "https://etherscan.io"
|
||||
: "https://polygonscan.com";
|
||||
return `${base}/tx/${txHash}`;
|
||||
}
|
||||
|
||||
function severityTone(severity?: string) {
|
||||
if (severity === "high") return "border-red-200 bg-red-50 text-red-700";
|
||||
if (severity === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
return "border-slate-200 bg-slate-50 text-slate-600";
|
||||
}
|
||||
|
||||
function severityLabel(severity?: string) {
|
||||
if (severity === "high") return "高";
|
||||
if (severity === "medium") return "中";
|
||||
if (severity === "low") return "低";
|
||||
return severity || "未知";
|
||||
}
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "—";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function RiskStat({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
tone = "text-slate-950",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
sub: string;
|
||||
tone?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="text-xs font-bold uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<div className={`mt-1 text-2xl font-black ${tone}`}>{value}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentsPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
|
||||
const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
|
||||
const [payments, setPayments] = useState<PaymentRecord[]>([]);
|
||||
const [risk, setRisk] = useState<BillingRiskPayload | null>(null);
|
||||
const [resolving, setResolving] = useState<Set<number>>(new Set());
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rt, inc, pay] = await Promise.all([
|
||||
const [rt, inc, pay, riskPayload] = await Promise.all([
|
||||
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
|
||||
opsApi.incidents(50),
|
||||
opsApi.listPayments(50),
|
||||
opsApi.billingRisk(30, 80) as Promise<BillingRiskPayload>,
|
||||
]);
|
||||
setRuntime(rt);
|
||||
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
|
||||
setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []);
|
||||
setRisk(riskPayload);
|
||||
} catch { /* */ }
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -60,18 +122,10 @@ export function PaymentsPageClient() {
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
|
||||
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
|
||||
|
||||
function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
const txHash = String(payment.tx_hash || "").trim();
|
||||
if (!txHash) return "";
|
||||
const chain = String(payment.chain || "").trim().toLowerCase();
|
||||
const base = chain.includes("eth")
|
||||
? "https://etherscan.io"
|
||||
: "https://polygonscan.com";
|
||||
return `${base}/tx/${txHash}`;
|
||||
}
|
||||
const riskSummary = risk?.summary ?? {};
|
||||
const riskIssues = risk?.issues ?? [];
|
||||
const referralRewards = risk?.recent_referral_rewards ?? [];
|
||||
const hasRisk = Number(riskSummary.issues ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -82,6 +136,73 @@ function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{hasRisk ? <AlertTriangle className="h-4 w-4 text-amber-500" /> : <ShieldCheck className="h-4 w-4 text-emerald-500" />}
|
||||
支付与邀请风控流水
|
||||
</CardTitle>
|
||||
<span className="text-xs text-slate-500">最近 {risk?.window_days ?? 30} 天 · {compactDate(risk?.checked_at)}</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
<RiskStat label="总异常" value={Number(riskSummary.issues ?? 0)} sub="需要人工关注" tone={hasRisk ? "text-red-600" : "text-emerald-600"} />
|
||||
<RiskStat label="Intent 卡住" value={Number(riskSummary.stuck_intents ?? 0)} sub="submitted/过期 created" />
|
||||
<RiskStat label="试用漏开" value={Number(riskSummary.trial_gaps ?? 0)} sub="signup 无 trial_created" />
|
||||
<RiskStat label="支付异常" value={Number(riskSummary.payment_incidents ?? incidents.length)} sub="未标记处理" />
|
||||
<RiskStat label="积分异常" value={Number(riskSummary.points_discount_issues ?? 0)} sub="确认后未扣/少扣" />
|
||||
<RiskStat label="推荐异常" value={Number(riskSummary.referral_settlement_issues ?? 0)} sub="转化无奖励记录" />
|
||||
<RiskStat label="上限命中" value={Number(riskSummary.monthly_cap_hits ?? 0)} sub="月度邀请封顶" />
|
||||
</div>
|
||||
|
||||
{risk?.query_errors?.length ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
部分 Supabase 表查询失败:{risk.query_errors.map((item) => item.table).filter(Boolean).join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{riskIssues.length === 0 ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||
暂无试用、支付、推荐奖励或积分抵扣风险信号。
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="py-2 pr-4 font-bold">级别</th>
|
||||
<th className="py-2 pr-4 font-bold">类型</th>
|
||||
<th className="py-2 pr-4 font-bold">问题</th>
|
||||
<th className="py-2 pr-4 font-bold">用户</th>
|
||||
<th className="py-2 pr-4 font-bold">时间</th>
|
||||
<th className="py-2 pr-4 font-bold">引用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{riskIssues.slice(0, 30).map((issue: BillingRiskIssue, index) => (
|
||||
<tr key={`${issue.category}-${issue.reference}-${index}`} className="border-b border-slate-100">
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`inline-flex min-w-8 items-center justify-center rounded-md border px-2 py-1 text-xs font-bold ${severityTone(issue.severity)}`}>
|
||||
{severityLabel(issue.severity)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-500">{issue.category ?? "—"}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="font-bold text-slate-900">{issue.title ?? "—"}</div>
|
||||
<div className="mt-0.5 max-w-xl text-xs text-slate-500">{issue.detail ?? "—"}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{issue.user_id ? `${issue.user_id.slice(0, 10)}...` : "—"}</td>
|
||||
<td className="py-2 pr-4 whitespace-nowrap text-xs text-slate-500">{compactDate(issue.created_at)}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-blue-700">{issue.reference ? String(issue.reference).slice(0, 14) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader><CardTitle>支付运行时</CardTitle></CardHeader>
|
||||
@@ -113,42 +234,48 @@ function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
{incidentPieData.length === 0 ? (
|
||||
<span className="text-sm text-slate-500">暂无异常数据</span>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="w-[100px] h-[100px] shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={incidentPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={24}
|
||||
outerRadius={40}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{incidentPieData.map((d, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ ...CHART_TOOLTIP_STYLE, fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-y-auto max-h-[100px] text-xs">
|
||||
{incidentPieData.map((d, i) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5 truncate">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} />
|
||||
<span className="text-slate-400 truncate" title={d.name}>{d.name}</span>
|
||||
<span className="text-white font-bold ml-auto">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PaymentIncidentPieChart data={incidentPieData} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>推荐奖励结算 ({referralRewards.length})</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{referralRewards.length === 0 ? (
|
||||
<span className="text-sm text-slate-500">最近没有推荐奖励记录</span>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="py-2 pr-4 font-bold">ID</th>
|
||||
<th className="py-2 pr-4 font-bold">邀请人</th>
|
||||
<th className="py-2 pr-4 font-bold">被邀请人</th>
|
||||
<th className="py-2 pr-4 font-bold">奖励</th>
|
||||
<th className="py-2 pr-4 font-bold">Intent</th>
|
||||
<th className="py-2 pr-4 font-bold">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{referralRewards.slice(0, 20).map((row, index) => (
|
||||
<tr key={`${row.id}-${index}`} className="border-b border-slate-100">
|
||||
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.id ?? "—")}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.referrer_user_id ?? "—").slice(0, 10)}...</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.referred_user_id ?? "—").slice(0, 10)}...</td>
|
||||
<td className="py-2 pr-4 text-emerald-700 font-bold">+{Number(row.reward_points ?? 0).toLocaleString()} 分</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-blue-700">{String(row.payment_intent_id ?? "—").slice(0, 14)}</td>
|
||||
<td className="py-2 pr-4 whitespace-nowrap text-xs text-slate-500">{compactDate(String(row.created_at ?? ""))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>支付异常 ({incidents.length})</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw, ShieldCheck, Database, Cpu, HardDrive } from "lucide-react";
|
||||
import { AlertTriangle, RefreshCcw, ShieldCheck, Database, Cpu, HardDrive, RadioTower } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { SystemStatusPayload, HealthPayload } from "@/types/ops";
|
||||
import type { SourceHealthPayload, SystemStatusPayload, HealthPayload } from "@/types/ops";
|
||||
|
||||
function sourceStatusTone(status?: string) {
|
||||
if (status === "fresh") return "text-emerald-500";
|
||||
if (status === "expected_wait") return "text-blue-500";
|
||||
if (status === "delayed") return "text-amber-500";
|
||||
if (status === "stale" || status === "missing") return "text-red-500";
|
||||
return "text-slate-500";
|
||||
}
|
||||
|
||||
function sourceStatusLabel(status?: string) {
|
||||
if (status === "fresh") return "正常";
|
||||
if (status === "expected_wait") return "等待更新";
|
||||
if (status === "delayed") return "延迟";
|
||||
if (status === "stale") return "断线";
|
||||
if (status === "missing") return "缺失";
|
||||
return "未知";
|
||||
}
|
||||
|
||||
function formatAge(ageMin?: number | null) {
|
||||
if (ageMin == null) return "—";
|
||||
if (ageMin < 60) return `${Math.round(ageMin)}m`;
|
||||
return `${(ageMin / 60).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SystemPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [health, setHealth] = useState<HealthPayload | null>(null);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
const [sourceHealth, setSourceHealth] = useState<SourceHealthPayload | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [h, s] = await Promise.all([
|
||||
const [h, s, sh] = await Promise.all([
|
||||
opsApi.health(),
|
||||
opsApi.systemStatus() as Promise<SystemStatusPayload>,
|
||||
opsApi.sourceHealth(80) as Promise<SourceHealthPayload>,
|
||||
]);
|
||||
setHealth(h);
|
||||
setStatus(s);
|
||||
setSourceHealth(sh);
|
||||
} catch (e) {
|
||||
setError(String(e).slice(0, 200));
|
||||
} finally {
|
||||
@@ -45,6 +71,13 @@ export function SystemPageClient() {
|
||||
|
||||
const dbOk = status?.db?.ok ?? health?.db?.ok;
|
||||
const cacheAnalysis = status?.cache?.analysis;
|
||||
const sourceIssues = (sourceHealth?.cities || [])
|
||||
.flatMap((city) =>
|
||||
(city.sources || [])
|
||||
.filter((source) => ["delayed", "stale", "missing", "unknown"].includes(String(source.status || "")))
|
||||
.map((source) => ({ city: city.city, source })),
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -165,6 +198,73 @@ export function SystemPageClient() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RadioTower className="h-4 w-4 text-blue-500" />
|
||||
城市数据源健康
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
|
||||
{["fresh", "expected_wait", "delayed", "stale", "missing"].map((key) => (
|
||||
<div key={key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="text-[11px] text-slate-500">{sourceStatusLabel(key)}</div>
|
||||
<div className={`text-lg font-black ${sourceStatusTone(key)}`}>
|
||||
{sourceHealth?.status_counts?.[key] ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sourceIssues.length ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||
<table className="w-full min-w-[760px] text-left text-xs">
|
||||
<thead className="bg-slate-50 text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">城市</th>
|
||||
<th className="px-3 py-2">来源</th>
|
||||
<th className="px-3 py-2">状态</th>
|
||||
<th className="px-3 py-2">延迟</th>
|
||||
<th className="px-3 py-2">最近观测</th>
|
||||
<th className="px-3 py-2">原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sourceIssues.map(({ city, source }, index) => (
|
||||
<tr key={`${city}-${source.role}-${source.source_code}-${index}`} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 font-mono font-bold text-slate-800">{city}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-semibold text-slate-800">{source.source_label || source.source_code}</div>
|
||||
<div className="text-[11px] text-slate-500">{source.role}</div>
|
||||
</td>
|
||||
<td className={`px-3 py-2 font-bold ${sourceStatusTone(source.status)}`}>
|
||||
{sourceStatusLabel(source.status)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{formatAge(source.age_min)}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{source.observed_at || "—"}</td>
|
||||
<td className="px-3 py-2 text-slate-500">{source.reason || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
当前缓存内未发现 MGM、KNMI、IMS 或机场站断线/延迟异常。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sourceHealth?.cities || []).some((city) => !city.cache_exists) ? (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-700">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
有城市缺少 full/panel 缓存,可能是后台冷启动或该城市尚未预热。
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DB Path */}
|
||||
{status?.db?.db_path ? (
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
LabelList,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
type DebChartRow = {
|
||||
name: string;
|
||||
cityId: string;
|
||||
hitRate: number;
|
||||
mae: number;
|
||||
days: number;
|
||||
};
|
||||
|
||||
type MuChartRow = {
|
||||
name: string;
|
||||
cityId: string;
|
||||
brierScore: number;
|
||||
hitRate: number;
|
||||
mae: number;
|
||||
days: number;
|
||||
};
|
||||
|
||||
const CHART_COLORS = {
|
||||
green: "#22c55e",
|
||||
yellow: "#eab308",
|
||||
red: "#ef4444",
|
||||
};
|
||||
|
||||
function hitColor(hitRate: number) {
|
||||
if (hitRate >= 80) return CHART_COLORS.green;
|
||||
if (hitRate >= 60) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
function maeColor(mae: number) {
|
||||
if (mae <= 1.5) return CHART_COLORS.green;
|
||||
if (mae <= 2.5) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
function brierColor(score: number) {
|
||||
if (score <= 0.1) return CHART_COLORS.green;
|
||||
if (score <= 0.25) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
export function TrainingAccuracyCharts({
|
||||
debChartData,
|
||||
muChartData,
|
||||
}: {
|
||||
debChartData: DebChartRow[];
|
||||
muChartData: MuChartRow[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{debChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB MAE by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
||||
/>
|
||||
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={maeColor(entry.mae)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="mae" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}°`} />
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{muChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>概率 μ Brier Score by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
|
||||
/>
|
||||
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{muChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={brierColor(entry.brierScore)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="brierScore" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => Number(v).toFixed(3)} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>概率 μ 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{muChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { SystemStatusPayload } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
ComposedChart, Line, Legend, Cell, LabelList,
|
||||
} from "recharts";
|
||||
|
||||
const TrainingAccuracyCharts = dynamic(
|
||||
() => import("./TrainingAccuracyCharts").then((mod) => mod.TrainingAccuracyCharts),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[400px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
@@ -61,37 +65,6 @@ interface CityAccuracy {
|
||||
} | null;
|
||||
}
|
||||
|
||||
const CHART_COLORS = {
|
||||
green: "#22c55e",
|
||||
yellow: "#eab308",
|
||||
red: "#ef4444",
|
||||
blue: "#3b82f6",
|
||||
cyan: "#06b6d4",
|
||||
purple: "#a855f7",
|
||||
slate: "#64748b",
|
||||
emerald: "#10b981",
|
||||
amber: "#f59e0b",
|
||||
rose: "#f43f5e",
|
||||
};
|
||||
|
||||
function hitColor(hitRate: number) {
|
||||
if (hitRate >= 80) return CHART_COLORS.green;
|
||||
if (hitRate >= 60) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
function maeColor(mae: number) {
|
||||
if (mae <= 1.5) return CHART_COLORS.green;
|
||||
if (mae <= 2.5) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
function brierColor(score: number) {
|
||||
if (score <= 0.1) return CHART_COLORS.green;
|
||||
if (score <= 0.25) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
export function TrainingPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
@@ -221,115 +194,7 @@ export function TrainingPageClient() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* DEB Accuracy Charts */}
|
||||
{debChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB MAE by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
||||
/>
|
||||
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={maeColor(entry.mae)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="mae" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}°`} />
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Mu Probability Charts */}
|
||||
{muChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>概率 μ Brier Score by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
|
||||
/>
|
||||
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{muChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={brierColor(entry.brierScore)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="brierScore" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => Number(v).toFixed(3)} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>概率 μ 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{muChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
<TrainingAccuracyCharts debChartData={debChartData} muChartData={muChartData} />
|
||||
|
||||
{/* City coverage */}
|
||||
{modelCities ? (
|
||||
|
||||
Reference in New Issue
Block a user