import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useRequestLogs, usageKeys } from "@/lib/query/usage"; import { useQueryClient } from "@tanstack/react-query"; import type { LogFilters } from "@/types/usage"; import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react"; import { fmtInt, fmtUsd, getLocaleFromLanguage, parseFiniteNumber, } from "./format"; interface RequestLogTableProps { refreshIntervalMs: number; } const ONE_DAY_SECONDS = 24 * 60 * 60; const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; type TimeMode = "rolling" | "fixed"; export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const getRollingRange = () => { const now = Math.floor(Date.now() / 1000); const oneDayAgo = now - ONE_DAY_SECONDS; return { startDate: oneDayAgo, endDate: now }; }; const [appliedTimeMode, setAppliedTimeMode] = useState("rolling"); const [draftTimeMode, setDraftTimeMode] = useState("rolling"); const [appliedFilters, setAppliedFilters] = useState({}); const [draftFilters, setDraftFilters] = useState({}); const [page, setPage] = useState(0); const pageSize = 20; const [validationError, setValidationError] = useState(null); const { data: result, isLoading } = useRequestLogs({ filters: appliedFilters, timeMode: appliedTimeMode, rollingWindowSeconds: ONE_DAY_SECONDS, page, pageSize, options: { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }, }); const logs = result?.data ?? []; const total = result?.total ?? 0; const totalPages = Math.ceil(total / pageSize); const handleSearch = () => { setValidationError(null); if (draftTimeMode === "fixed") { const start = draftFilters.startDate; const end = draftFilters.endDate; if (typeof start !== "number" || typeof end !== "number") { setValidationError( t("usage.invalidTimeRange", "请选择完整的开始/结束时间"), ); return; } if (start > end) { setValidationError( t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"), ); return; } if (end - start > MAX_FIXED_RANGE_SECONDS) { setValidationError( t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"), ); return; } } setAppliedTimeMode(draftTimeMode); setAppliedFilters((prev) => { const next = { ...prev, ...draftFilters }; if (draftTimeMode === "rolling") { delete next.startDate; delete next.endDate; } return next; }); setPage(0); }; const handleReset = () => { setValidationError(null); setAppliedTimeMode("rolling"); setDraftTimeMode("rolling"); setDraftFilters({}); setAppliedFilters({}); setPage(0); }; const handleRefresh = () => { const key = { timeMode: appliedTimeMode, rollingWindowSeconds: appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined, appType: appliedFilters.appType, providerName: appliedFilters.providerName, model: appliedFilters.model, statusCode: appliedFilters.statusCode, startDate: appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined, endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined, }; queryClient.invalidateQueries({ queryKey: usageKeys.logs(key, page, pageSize), }); }; // 将 Unix 时间戳转换为本地时间的 datetime-local 格式 const timestampToLocalDatetime = (timestamp: number): string => { const date = new Date(timestamp * 1000); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day}T${hours}:${minutes}`; }; // 将 datetime-local 格式转换为 Unix 时间戳 const localDatetimeToTimestamp = (datetime: string): number | undefined => { if (!datetime) return undefined; // 验证格式是否完整 (YYYY-MM-DDTHH:mm) if (datetime.length < 16) return undefined; const timestamp = new Date(datetime).getTime(); // 验证是否为有效日期 if (isNaN(timestamp)) return undefined; return Math.floor(timestamp / 1000); }; const language = i18n.resolvedLanguage || i18n.language || "en"; const locale = getLocaleFromLanguage(language); const rollingRangeForDisplay = draftTimeMode === "rolling" ? getRollingRange() : null; return (
{/* 筛选栏 */}
setDraftFilters({ ...draftFilters, providerName: e.target.value || undefined, }) } />
setDraftFilters({ ...draftFilters, model: e.target.value || undefined, }) } />
{t("usage.timeRange")}: { const timestamp = localDatetimeToTimestamp(e.target.value); setDraftTimeMode("fixed"); setDraftFilters({ ...draftFilters, startDate: timestamp, }); }} /> - { const timestamp = localDatetimeToTimestamp(e.target.value); setDraftTimeMode("fixed"); setDraftFilters({ ...draftFilters, endDate: timestamp, }); }} />
{validationError && (
{validationError}
)}
{isLoading ? (
) : ( <>
{t("usage.time")} {t("usage.provider")} {t("usage.billingModel")} {t("usage.inputTokens")} {t("usage.outputTokens")} {t("usage.cacheReadTokens")} {t("usage.cacheCreationTokens")} {t("usage.multiplier")} {t("usage.totalCost")} {t("usage.timingInfo")} {t("usage.status")} {logs.length === 0 ? ( {t("usage.noData")} ) : ( logs.map((log) => ( {new Date(log.createdAt * 1000).toLocaleString(locale)} {log.providerName || t("usage.unknownProvider")}
{log.model}
{log.requestModel && log.requestModel !== log.model && (
← {log.requestModel}
)}
{fmtInt(log.inputTokens, locale)} {fmtInt(log.outputTokens, locale)} {fmtInt(log.cacheReadTokens, locale)} {fmtInt(log.cacheCreationTokens, locale)} {(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? ( ×{log.costMultiplier} ) : ( ×1 )} {fmtUsd(log.totalCostUsd, 6)}
{(() => { const durationMs = typeof log.durationMs === "number" ? log.durationMs : log.latencyMs; const durationSec = durationMs / 1000; const durationColor = Number.isFinite(durationSec) ? durationSec <= 5 ? "bg-green-100 text-green-800" : durationSec <= 120 ? "bg-orange-100 text-orange-800" : "bg-red-200 text-red-900" : "bg-gray-100 text-gray-700"; return ( {Number.isFinite(durationSec) ? `${Math.round(durationSec)}s` : "--"} ); })()} {log.isStreaming && log.firstTokenMs != null && (() => { const firstSec = log.firstTokenMs / 1000; const firstColor = Number.isFinite(firstSec) ? firstSec <= 5 ? "bg-green-100 text-green-800" : firstSec <= 120 ? "bg-orange-100 text-orange-800" : "bg-red-200 text-red-900" : "bg-gray-100 text-gray-700"; return ( {Number.isFinite(firstSec) ? `${firstSec.toFixed(1)}s` : "--"} ); })()} {log.isStreaming ? t("usage.stream") : t("usage.nonStream")}
= 200 && log.statusCode < 300 ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800" }`} > {log.statusCode}
)) )}
{/* 分页控件 */} {total > 0 && (
{t("usage.totalRecords", { total })}
{/* 页码按钮 */} {(() => { const pages: (number | string)[] = []; if (totalPages <= 7) { for (let i = 0; i < totalPages; i++) pages.push(i); } else { pages.push(0); if (page > 2) pages.push("..."); for ( let i = Math.max(1, page - 1); i <= Math.min(totalPages - 2, page + 1); i++ ) { pages.push(i); } if (page < totalPages - 3) pages.push("..."); pages.push(totalPages - 1); } return pages.map((p, idx) => typeof p === "string" ? ( ... ) : ( ), ); })()}
)} )}
); }