import { startTransition, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { apiClient } from "@/api/client"; import { AnalyticsEventItem, AnalyticsRange, UsageLogFilterDimension, UsageLogFilters, UsageLogSearchData, } from "@/types"; import { PageContainer } from "@/components/ui/page-container"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; 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 { useBillingConfig } from "@/hooks/use-billing-config"; import { readScopedCache, writeScopedCache } from "@/lib/local-cache"; import { cn, formatCompactNumber, formatCurrency, parseUtcTimestamp } from "@/lib/utils"; import { Eye, RefreshCw, Search } from "lucide-react"; type UsageLogFilterState = { range: AnalyticsRange; dimension: UsageLogFilterDimension; keyword: string; 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: "渠道标识" }, { value: "model", label: "请求模型" }, { value: "provider", label: "服务商类型" }, { value: "route", label: "接口路由" }, { value: "requestId", label: "Request ID" }, { value: "traceId", label: "Trace ID" }, { value: "clientIp", label: "请求 IP" }, { value: "userAgent", label: "User-Agent" }, { value: "country", label: "国家地区" }, { value: "region", label: "省州 / Region" }, { value: "city", label: "城市 / City" }, { value: "colo", label: "边缘节点 / Colo" }, { value: "timezone", label: "时区 / Timezone" }, { value: "result", label: "结果" }, { value: "errorCode", label: "错误码" }, { value: "errorSummary", label: "错误摘要" }, ]; const RESULT_OPTIONS: Array<{ value: "all" | "success" | "failure"; label: string }> = [ { value: "all", label: "全部状态" }, { value: "success", label: "仅成功" }, { value: "failure", label: "仅失败" }, ]; const RANGE_OPTIONS: Array<{ value: AnalyticsRange; label: string }> = [ { value: "24h", label: "24 小时" }, { value: "7d", label: "7 天" }, { value: "30d", label: "30 天" }, { value: "90d", label: "90 天" }, ]; const PAGINATION_WINDOW_SIZE = 5; const USAGE_LOGS_PAGE_CACHE_KEY = "usage-logs:page:v2"; const USAGE_LOG_RANGE_DURATION_MS: Record = { "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, "90d": 90 * 24 * 60 * 60 * 1000, }; const isAnalyticsRange = (value: unknown): value is AnalyticsRange => { return RANGE_OPTIONS.some((option) => option.value === value); }; const createFilterPreset = (range: AnalyticsRange): UsageLogFilterState => { return { range, dimension: "token", keyword: "", result: "all", }; }; const hydrateUsageLogFilters = ( filters: UsageLogFilterState | undefined, fallback: UsageLogFilterState, ): UsageLogFilterState => { return { range: isAnalyticsRange(filters?.range) ? filters.range : fallback.range, dimension: filters?.dimension ?? fallback.dimension, keyword: filters?.keyword ?? fallback.keyword, result: filters?.result ?? fallback.result, }; }; const formatDateTime = (value: string): string => { const date = parseUtcTimestamp(value); if (!date) { return value; } return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); }; const formatDuration = (value: number): string => { if (value >= 1000) { return `${(value / 1000).toFixed(1)} s`; } return `${Math.round(value)} ms`; }; const getUsageLogWindow = (range: AnalyticsRange): { start: Date; end: Date } => { const end = new Date(); const start = new Date(end.getTime() - USAGE_LOG_RANGE_DURATION_MS[range]); return { start, end }; }; const buildSearchParams = (filters: UsageLogFilterState, page: number): UsageLogFilters => { const { start, end } = getUsageLogWindow(filters.range); return { start: start.toISOString(), end: end.toISOString(), dimension: filters.dimension, keyword: filters.keyword.trim() || undefined, result: filters.result, page, }; }; const isSameUsageLogFilterState = (left: UsageLogFilterState, right: UsageLogFilterState): boolean => { return ( left.range === right.range && 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); return (
{item.clientIp || "--"}
{locationParts.length > 0 ? locationParts.join(" / ") : "未知位置"}
{item.colo || "--"} {item.timezone ? `· ${item.timezone}` : ""}
); }; const formatDetailValue = (value: string | number | null | undefined): string => { if (value === null || value === undefined) { return "--"; } if (typeof value === "string" && value.trim().length === 0) { return "--"; } return String(value); }; const DetailMetric = ({ label, value }: { label: string; value: string | number }) => { const text = formatDetailValue(value); return (
{label}
{text}
); }; const DetailSection = ({ title, children }: { title: string; children: React.ReactNode }) => { return (
{title}
{children}
); }; const DetailRow = ({ label, value, mono = false, tone = "default", }: { label: string; value: string | number; mono?: boolean; tone?: "default" | "success" | "danger" | "subtle"; }) => { const text = formatDetailValue(value); const toneClassName = tone === "success" ? "text-emerald-600 dark:text-emerald-400" : tone === "danger" ? "text-rose-600 dark:text-rose-400" : tone === "subtle" ? "text-muted-foreground" : "text-foreground"; return (
{label}
{text}
); }; export function UsageLogs() { const { data: billingConfig } = useBillingConfig(); const displayDecimals = billingConfig?.displayDecimals ?? 6; const defaultFilters = useMemo(() => createFilterPreset("24h"), []); const cachedLogsSnapshot = useMemo(() => readScopedCache(USAGE_LOGS_PAGE_CACHE_KEY), []); const initialDraftFilters = useMemo( () => hydrateUsageLogFilters(cachedLogsSnapshot?.data.draftFilters, defaultFilters), [cachedLogsSnapshot, defaultFilters], ); const initialAppliedFilters = useMemo( () => hydrateUsageLogFilters(cachedLogsSnapshot?.data.appliedFilters, defaultFilters), [cachedLogsSnapshot, defaultFilters], ); const [draftFilters, setDraftFilters] = useState(() => initialDraftFilters); const [appliedFilters, setAppliedFilters] = useState(() => initialAppliedFilters); const [currentPage, setCurrentPage] = useState(() => cachedLogsSnapshot?.data.currentPage ?? 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)); 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; const totalPages = logsQuery.data?.totalPages ?? 0; const totalItems = logsQuery.data?.total ?? 0; const pageSize = logsQuery.data?.pageSize ?? 50; const currentStart = totalItems > 0 ? (activePage - 1) * pageSize + 1 : 0; const currentEnd = totalItems > 0 ? currentStart + (logsQuery.data?.count ?? 0) - 1 : 0; const visiblePages = useMemo(() => { if (totalPages <= 1) { return totalPages === 1 ? [1] : []; } const halfWindow = Math.floor(PAGINATION_WINDOW_SIZE / 2); let start = Math.max(1, activePage - halfWindow); let end = Math.min(totalPages, start + PAGINATION_WINDOW_SIZE - 1); start = Math.max(1, end - PAGINATION_WINDOW_SIZE + 1); return Array.from({ length: end - start + 1 }, (_, index) => start + index); }, [activePage, totalPages]); const handleSearch = () => { startTransition(() => { setCurrentPage(1); setAppliedFilters({ ...draftFilters }); }); }; const handlePageChange = (page: number) => { if (page < 1 || page === activePage || (totalPages > 0 && page > totalPages)) { return; } startTransition(() => { setCurrentPage(page); }); }; return ( } >
{logsQuery.data?.compatibilityWarning && ( {logsQuery.data.compatibilityWarning} )}
setDraftFilters((current) => ({ ...current, keyword: event.target.value }))} placeholder="支持 ILIKE 模糊匹配" />
{logsQuery.isLoading ? (
日志加载中...
) : logsQuery.data?.items.length ? (
{logsQuery.data.items.map((item) => ( ))}
时间 模型 / 令牌 端点 / 渠道 结果 / 状态 方式 / 用时 资源消耗 详情
{formatDateTime(item.timestamp)}
{item.requestId || "--"}
{item.requestedModel || "--"}
{item.tokenName || "未命名令牌"}
{item.providerType || "--"}
{item.channelKey || "--"}
{item.result === "success" ? "成功" : "失败"}
{item.upstreamStatus || "--"} · 重试 {item.retryCount}
{item.streamMode === "stream" ? "流式" : "非流式"}
{formatDuration(item.latencyMs)}
{/*
{formatCompactNumber(item.totalTokens)} tokens
*/}
{formatCompactNumber(item.promptTokens)} / {formatCompactNumber(item.completionTokens)} /{" "} {formatCompactNumber(item.cachedTokens)}
{formatCurrency(item.totalCost, displayDecimals)}
{totalItems > 0 ? `显示第 ${currentStart}-${currentEnd} 条,共 ${totalItems} 条` : "暂无记录"}
{visiblePages.map((page) => ( ))}
) : (

没有匹配的日志记录

调整时间范围、维度或关键字后重试。

)}
!open && setSelectedItem(null)}> 日志详情 {selectedItem && (
{selectedItem.result === "success" ? "成功" : "失败"} {selectedItem.streamMode === "stream" ? "流式" : "非流式"} {formatDetailValue(selectedItem.routeId)}
{formatDetailValue(selectedItem.requestedModel || selectedItem.upstreamModel)}
令牌
{formatDetailValue(selectedItem.tokenName)}
端点
{formatDetailValue(selectedItem.providerType)}
渠道
{formatDetailValue(selectedItem.channelKey)}
{selectedItem.result !== "success" && selectedItem.errorSummary && (
{formatDetailValue(selectedItem.errorSummary)}
)}
)}
); }