From 0183be8fa0c521ed43d9af07313c4df27fd80d70 Mon Sep 17 00:00:00 2001 From: Jeff Gu Date: Sat, 11 Apr 2026 21:43:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(cache):=20=E6=B7=BB=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=BC=93=E5=AD=98=E5=8A=9F=E8=83=BD=E4=BB=A5=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=95=B0=E6=8D=AE=E5=AD=98=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/local-cache.ts | 103 +++++++++++++++++++++++++++++++ frontend/src/pages/Analytics.tsx | 82 +++++++++++++++++++++--- frontend/src/pages/UsageLogs.tsx | 47 ++++++++++++-- frontend/src/store/auth.ts | 3 + 4 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/local-cache.ts diff --git a/frontend/src/lib/local-cache.ts b/frontend/src/lib/local-cache.ts new file mode 100644 index 0000000..3f9ec0a --- /dev/null +++ b/frontend/src/lib/local-cache.ts @@ -0,0 +1,103 @@ +const CACHE_PREFIX = "one-api-cache"; +const CACHE_VERSION = 1; +const ADMIN_TOKEN_KEY = "adminToken"; + +export interface LocalCacheEntry { + 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 = (key: string): LocalCacheEntry | 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; + } catch { + window.localStorage.removeItem(getStorageKey(key)); + return null; + } +}; + +export const writeScopedCache = (key: string, data: T): LocalCacheEntry | null => { + if (!canUseLocalStorage()) { + return null; + } + + const entry: LocalCacheEntry = { + 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); + }); +}; diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index 0d3abab..e70c702 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -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(ANALYTICS_RANGE_CACHE_KEY)?.data; + + return RANGE_OPTIONS.some((option) => option.value === cachedRange) ? cachedRange : "7d"; +}; + export function Analytics() { - const [range, setRange] = useState("7d"); + const [range, setRange] = useState(getInitialAnalyticsRange); + + const overviewCacheEntry = useMemo( + () => readScopedCache(getAnalyticsOverviewCacheKey(range)), + [range], + ); + const trendCacheEntry = useMemo(() => readScopedCache(getAnalyticsTrendCacheKey(range)), [range]); + const tokenBreakdownCacheEntry = useMemo( + () => readScopedCache(getAnalyticsBreakdownCacheKey(range, "token")), + [range], + ); + const channelBreakdownCacheEntry = useMemo( + () => readScopedCache(getAnalyticsBreakdownCacheKey(range, "channel")), + [range], + ); + const modelBreakdownCacheEntry = useMemo( + () => readScopedCache(getAnalyticsBreakdownCacheKey(range, "model")), + [range], + ); + const providerBreakdownCacheEntry = useMemo( + () => readScopedCache(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 = { diff --git a/frontend/src/pages/UsageLogs.tsx b/frontend/src/pages/UsageLogs.tsx index 4bac98b..2973b80 100644 --- a/frontend/src/pages/UsageLogs.tsx +++ b/frontend/src/pages/UsageLogs.tsx @@ -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(defaultFilters); - const [appliedFilters, setAppliedFilters] = useState(defaultFilters); - const [currentPage, setCurrentPage] = useState(1); + const cachedLogsSnapshot = useMemo(() => readScopedCache(USAGE_LOGS_PAGE_CACHE_KEY), []); + const [draftFilters, setDraftFilters] = useState( + () => cachedLogsSnapshot?.data.draftFilters ?? defaultFilters, + ); + const [appliedFilters, setAppliedFilters] = useState( + () => cachedLogsSnapshot?.data.appliedFilters ?? defaultFilters, + ); + const [currentPage, setCurrentPage] = useState(() => Math.max(cachedLogsSnapshot?.data.currentPage ?? 1, 1)); const [selectedItem, setSelectedItem] = useState(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; diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 1da616b..6a281ef 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -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((set) => ({ }, logout: () => { + clearScopedCacheByPrefix('analytics:') + clearScopedCacheByPrefix('usage-logs:') localStorage.removeItem('adminToken') set({ isAuthenticated: false, error: null }) },