feat(cache): 添加本地缓存功能以优化数据存取
This commit is contained in:
1 parent
822b2fad4c
commit
0183be8fa0
4 files changed
+223
-12
No files matched your search
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user