feat(cache): 添加本地缓存功能以优化数据存取

This commit is contained in:
Jeff Gu committed 2026-04-11 21:43:58 +08:00
1 parent 822b2fad4c
commit 0183be8fa0
4 files changed
+223 -12

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
const CACHE_PREFIX = "one-api-cache";
const CACHE_VERSION = 1;
const ADMIN_TOKEN_KEY = "adminToken";
export interface LocalCacheEntry<T> {
version: number;
updatedAt: number;
data: T;
}
const canUseLocalStorage = (): boolean => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
const getAdminTokenScope = (): string => {
if (!canUseLocalStorage()) {
return "server";
}
const adminToken = window.localStorage.getItem(ADMIN_TOKEN_KEY) || "anonymous";
let hash = 2166136261;
for (let index = 0; index < adminToken.length; index += 1) {
hash ^= adminToken.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `scope-${(hash >>> 0).toString(36)}`;
};
const getStorageKey = (key: string): string => {
return `${CACHE_PREFIX}:v${CACHE_VERSION}:${getAdminTokenScope()}:${key}`;
};
export const readScopedCache = <T>(key: string): LocalCacheEntry<T> | null => {
if (!canUseLocalStorage()) {
return null;
}
try {
const storageKey = getStorageKey(key);
const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) {
return null;
}
const parsed = JSON.parse(rawValue) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
!("version" in parsed) ||
!("updatedAt" in parsed) ||
!("data" in parsed) ||
parsed.version !== CACHE_VERSION ||
typeof parsed.updatedAt !== "number"
) {
window.localStorage.removeItem(storageKey);
return null;
}
return parsed as LocalCacheEntry<T>;
} catch {
window.localStorage.removeItem(getStorageKey(key));
return null;
}
};
export const writeScopedCache = <T>(key: string, data: T): LocalCacheEntry<T> | null => {
if (!canUseLocalStorage()) {
return null;
}
const entry: LocalCacheEntry<T> = {
version: CACHE_VERSION,
updatedAt: Date.now(),
data,
};
try {
window.localStorage.setItem(getStorageKey(key), JSON.stringify(entry));
return entry;
} catch {
return null;
}
};
export const clearScopedCacheByPrefix = (prefix: string): void => {
if (!canUseLocalStorage()) {
return;
}
const storageKeyPrefix = getStorageKey(prefix);
const keysToRemove: string[] = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (key?.startsWith(storageKeyPrefix)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => {
window.localStorage.removeItem(key);
});
};
+74 -8
View File
@@ -13,9 +13,10 @@ import { PageContainer } from "@/components/ui/page-container";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select } from "@/components/ui/select";
import { readScopedCache, writeScopedCache } from "@/lib/local-cache";
import { cn, formatCurrency } from "@/lib/utils";
import { Activity, CircleDollarSign, Clock3, DatabaseZap, RefreshCw, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
const RANGE_OPTIONS: Array<{ value: AnalyticsRange; label: string }> = [
{ value: "24h", label: "24 小时" },
@@ -77,55 +78,120 @@ const formatDuration = (value: number): string => {
return `${Math.round(value)} ms`;
};
const ANALYTICS_RANGE_CACHE_KEY = "analytics:selected-range";
const getAnalyticsOverviewCacheKey = (range: AnalyticsRange): string => `analytics:overview:${range}`;
const getAnalyticsTrendCacheKey = (range: AnalyticsRange): string => `analytics:trend:${range}`;
const getAnalyticsBreakdownCacheKey = (range: AnalyticsRange, dimension: AnalyticsBreakdownDimension): string =>
`analytics:breakdown:${range}:${dimension}`;
const getInitialAnalyticsRange = (): AnalyticsRange => {
const cachedRange = readScopedCache<AnalyticsRange>(ANALYTICS_RANGE_CACHE_KEY)?.data;
return RANGE_OPTIONS.some((option) => option.value === cachedRange) ? cachedRange : "7d";
};
export function Analytics() {
const [range, setRange] = useState<AnalyticsRange>("7d");
const [range, setRange] = useState<AnalyticsRange>(getInitialAnalyticsRange);
const overviewCacheEntry = useMemo(
() => readScopedCache<AnalyticsOverviewData>(getAnalyticsOverviewCacheKey(range)),
[range],
);
const trendCacheEntry = useMemo(() => readScopedCache<AnalyticsTrendData>(getAnalyticsTrendCacheKey(range)), [range]);
const tokenBreakdownCacheEntry = useMemo(
() => readScopedCache<AnalyticsBreakdownData>(getAnalyticsBreakdownCacheKey(range, "token")),
[range],
);
const channelBreakdownCacheEntry = useMemo(
() => readScopedCache<AnalyticsBreakdownData>(getAnalyticsBreakdownCacheKey(range, "channel")),
[range],
);
const modelBreakdownCacheEntry = useMemo(
() => readScopedCache<AnalyticsBreakdownData>(getAnalyticsBreakdownCacheKey(range, "model")),
[range],
);
const providerBreakdownCacheEntry = useMemo(
() => readScopedCache<AnalyticsBreakdownData>(getAnalyticsBreakdownCacheKey(range, "provider")),
[range],
);
useEffect(() => {
writeScopedCache(ANALYTICS_RANGE_CACHE_KEY, range);
}, [range]);
const overviewQuery = useQuery({
queryKey: ["analytics", "overview", range],
queryFn: async () => {
const response = await apiClient.getAnalyticsOverview(range);
return response.data as AnalyticsOverviewData;
const data = response.data as AnalyticsOverviewData;
writeScopedCache(getAnalyticsOverviewCacheKey(range), data);
return data;
},
initialData: overviewCacheEntry?.data,
initialDataUpdatedAt: overviewCacheEntry?.updatedAt,
});
const trendQuery = useQuery({
queryKey: ["analytics", "trend", range],
queryFn: async () => {
const response = await apiClient.getAnalyticsTrend(range);
return response.data as AnalyticsTrendData;
const data = response.data as AnalyticsTrendData;
writeScopedCache(getAnalyticsTrendCacheKey(range), data);
return data;
},
initialData: trendCacheEntry?.data,
initialDataUpdatedAt: trendCacheEntry?.updatedAt,
});
const tokenBreakdownQuery = useQuery({
queryKey: ["analytics", "breakdown", range, "token"],
queryFn: async () => {
const response = await apiClient.getAnalyticsBreakdown(range, "token");
return response.data as AnalyticsBreakdownData;
const data = response.data as AnalyticsBreakdownData;
writeScopedCache(getAnalyticsBreakdownCacheKey(range, "token"), data);
return data;
},
initialData: tokenBreakdownCacheEntry?.data,
initialDataUpdatedAt: tokenBreakdownCacheEntry?.updatedAt,
});
const channelBreakdownQuery = useQuery({
queryKey: ["analytics", "breakdown", range, "channel"],
queryFn: async () => {
const response = await apiClient.getAnalyticsBreakdown(range, "channel");
return response.data as AnalyticsBreakdownData;
const data = response.data as AnalyticsBreakdownData;
writeScopedCache(getAnalyticsBreakdownCacheKey(range, "channel"), data);
return data;
},
initialData: channelBreakdownCacheEntry?.data,
initialDataUpdatedAt: channelBreakdownCacheEntry?.updatedAt,
});
const modelBreakdownQuery = useQuery({
queryKey: ["analytics", "breakdown", range, "model"],
queryFn: async () => {
const response = await apiClient.getAnalyticsBreakdown(range, "model");
return response.data as AnalyticsBreakdownData;
const data = response.data as AnalyticsBreakdownData;
writeScopedCache(getAnalyticsBreakdownCacheKey(range, "model"), data);
return data;
},
initialData: modelBreakdownCacheEntry?.data,
initialDataUpdatedAt: modelBreakdownCacheEntry?.updatedAt,
});
const providerBreakdownQuery = useQuery({
queryKey: ["analytics", "breakdown", range, "provider"],
queryFn: async () => {
const response = await apiClient.getAnalyticsBreakdown(range, "provider");
return response.data as AnalyticsBreakdownData;
const data = response.data as AnalyticsBreakdownData;
writeScopedCache(getAnalyticsBreakdownCacheKey(range, "provider"), data);
return data;
},
initialData: providerBreakdownCacheEntry?.data,
initialDataUpdatedAt: providerBreakdownCacheEntry?.updatedAt,
});
const breakdownDataMap: Record<AnalyticsBreakdownDimension, AnalyticsBreakdownData | undefined> = {
+43 -4
View File
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { readScopedCache, writeScopedCache } from "@/lib/local-cache";
import { cn, formatCurrency } from "@/lib/utils";
import { Eye, RefreshCw, RotateCcw, Search } from "lucide-react";
@@ -20,6 +21,13 @@ type UsageLogFilterState = {
result: "all" | "success" | "failure";
};
type UsageLogPageCacheSnapshot = {
draftFilters: UsageLogFilterState;
appliedFilters: UsageLogFilterState;
currentPage: number;
data: UsageLogSearchData;
};
const FILTER_DIMENSIONS: Array<{ value: UsageLogFilterDimension; label: string }> = [
{ value: "token", label: "令牌名称" },
{ value: "channel", label: "渠道标识" },
@@ -47,6 +55,7 @@ const RESULT_OPTIONS: Array<{ value: "all" | "success" | "failure"; label: strin
];
const PAGINATION_WINDOW_SIZE = 5;
const USAGE_LOGS_PAGE_CACHE_KEY = "usage-logs:page";
const toDateTimeLocalValue = (date: Date): string => {
const offset = date.getTimezoneOffset() * 60_000;
@@ -108,6 +117,16 @@ const buildSearchParams = (filters: UsageLogFilterState, page: number): UsageLog
};
};
const isSameUsageLogFilterState = (left: UsageLogFilterState, right: UsageLogFilterState): boolean => {
return (
left.start === right.start &&
left.end === right.end &&
left.dimension === right.dimension &&
left.keyword === right.keyword &&
left.result === right.result
);
};
const ClientSummary = ({ item }: { item: AnalyticsEventItem }) => {
const locationParts = [item.country, item.region, item.city].filter(Boolean);
@@ -137,17 +156,37 @@ const DetailField = ({ label, value, mono = false }: { label: string; value: str
export function UsageLogs() {
const defaultFilters = useMemo(() => createFilterPreset(24), []);
const [draftFilters, setDraftFilters] = useState<UsageLogFilterState>(defaultFilters);
const [appliedFilters, setAppliedFilters] = useState<UsageLogFilterState>(defaultFilters);
const [currentPage, setCurrentPage] = useState(1);
const cachedLogsSnapshot = useMemo(() => readScopedCache<UsageLogPageCacheSnapshot>(USAGE_LOGS_PAGE_CACHE_KEY), []);
const [draftFilters, setDraftFilters] = useState<UsageLogFilterState>(
() => cachedLogsSnapshot?.data.draftFilters ?? defaultFilters,
);
const [appliedFilters, setAppliedFilters] = useState<UsageLogFilterState>(
() => cachedLogsSnapshot?.data.appliedFilters ?? defaultFilters,
);
const [currentPage, setCurrentPage] = useState(() => Math.max(cachedLogsSnapshot?.data.currentPage ?? 1, 1));
const [selectedItem, setSelectedItem] = useState<AnalyticsEventItem | null>(null);
const initialLogsData =
cachedLogsSnapshot?.data &&
cachedLogsSnapshot.data.currentPage === currentPage &&
isSameUsageLogFilterState(cachedLogsSnapshot.data.appliedFilters, appliedFilters)
? cachedLogsSnapshot.data.data
: undefined;
const logsQuery = useQuery({
queryKey: ["usage-logs", appliedFilters, currentPage],
queryFn: async () => {
const response = await apiClient.getUsageLogs(buildSearchParams(appliedFilters, currentPage));
return response.data as UsageLogSearchData;
const data = response.data as UsageLogSearchData;
writeScopedCache(USAGE_LOGS_PAGE_CACHE_KEY, {
draftFilters: appliedFilters,
appliedFilters,
currentPage: data.page,
data,
});
return data;
},
initialData: initialLogsData,
initialDataUpdatedAt: initialLogsData ? cachedLogsSnapshot?.updatedAt : undefined,
});
const activePage = logsQuery.data?.page ?? currentPage;
+3
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { apiClient } from '@/api/client'
import { clearScopedCacheByPrefix } from '@/lib/local-cache'
interface AuthState {
isAuthenticated: boolean
@@ -41,6 +42,8 @@ export const useAuthStore = create<AuthState>((set) => ({
},
logout: () => {
clearScopedCacheByPrefix('analytics:')
clearScopedCacheByPrefix('usage-logs:')
localStorage.removeItem('adminToken')
set({ isAuthenticated: false, error: null })
},