diff --git a/messages/en/myUsage.json b/messages/en/myUsage.json index 03e01c831..2d6aaab68 100644 --- a/messages/en/myUsage.json +++ b/messages/en/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "Key", "userStats": "User", "noData": "No data for selected period", - "unknownModel": "Unknown" + "unknownModel": "Unknown", + "modal": { + "requests": "Requests", + "tokens": "tokens", + "totalTokens": "Total Tokens", + "cost": "Cost", + "inputTokens": "Input Tokens", + "outputTokens": "Output Tokens", + "cacheWrite": "Cache Write", + "cacheRead": "Cache Read", + "cacheHitRate": "Cache Hit Rate", + "cacheTokens": "Cache Tokens", + "performanceHigh": "High", + "performanceMedium": "Medium", + "performanceLow": "Low" + } }, "accessRestrictions": { "title": "Access Restrictions", diff --git a/messages/ja/myUsage.json b/messages/ja/myUsage.json index 916f9ac39..66091789a 100644 --- a/messages/ja/myUsage.json +++ b/messages/ja/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "キー", "userStats": "ユーザー", "noData": "選択期間のデータがありません", - "unknownModel": "不明" + "unknownModel": "不明", + "modal": { + "requests": "リクエスト", + "tokens": "トークン", + "totalTokens": "トークン合計", + "cost": "コスト", + "inputTokens": "入力トークン", + "outputTokens": "出力トークン", + "cacheWrite": "キャッシュ書込", + "cacheRead": "キャッシュ読取", + "cacheHitRate": "キャッシュヒット率", + "cacheTokens": "キャッシュトークン", + "performanceHigh": "高", + "performanceMedium": "中", + "performanceLow": "低" + } }, "accessRestrictions": { "title": "アクセス制限", diff --git a/messages/ru/myUsage.json b/messages/ru/myUsage.json index 5f744bb21..886d1eeb6 100644 --- a/messages/ru/myUsage.json +++ b/messages/ru/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "Ключ", "userStats": "Пользователь", "noData": "Нет данных за выбранный период", - "unknownModel": "Неизвестно" + "unknownModel": "Неизвестно", + "modal": { + "requests": "Запросов", + "tokens": "токенов", + "totalTokens": "Всего токенов", + "cost": "Стоимость", + "inputTokens": "Входные токены", + "outputTokens": "Выходные токены", + "cacheWrite": "Запись кэша", + "cacheRead": "Чтение кэша", + "cacheHitRate": "Попадание кэша", + "cacheTokens": "Токены кэша", + "performanceHigh": "Высокий", + "performanceMedium": "Средний", + "performanceLow": "Низкий" + } }, "accessRestrictions": { "title": "Ограничения доступа", diff --git a/messages/zh-CN/myUsage.json b/messages/zh-CN/myUsage.json index 643048ea4..40740dbe1 100644 --- a/messages/zh-CN/myUsage.json +++ b/messages/zh-CN/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "密钥", "userStats": "用户", "noData": "所选时段无数据", - "unknownModel": "未知" + "unknownModel": "未知", + "modal": { + "requests": "请求", + "tokens": "个token", + "totalTokens": "总Token", + "cost": "费用", + "inputTokens": "输入Token", + "outputTokens": "输出Token", + "cacheWrite": "缓存写入", + "cacheRead": "缓存读取", + "cacheHitRate": "缓存命中率", + "cacheTokens": "缓存Token", + "performanceHigh": "高", + "performanceMedium": "中", + "performanceLow": "低" + } }, "accessRestrictions": { "title": "访问限制", diff --git a/messages/zh-TW/myUsage.json b/messages/zh-TW/myUsage.json index b72b9ffc3..4c473efa0 100644 --- a/messages/zh-TW/myUsage.json +++ b/messages/zh-TW/myUsage.json @@ -92,7 +92,22 @@ "keyStats": "金鑰", "userStats": "使用者", "noData": "所選時段無資料", - "unknownModel": "不明" + "unknownModel": "不明", + "modal": { + "requests": "請求", + "tokens": "個token", + "totalTokens": "總Token", + "cost": "費用", + "inputTokens": "輸入Token", + "outputTokens": "輸出Token", + "cacheWrite": "快取寫入", + "cacheRead": "快取讀取", + "cacheHitRate": "快取命中率", + "cacheTokens": "快取Token", + "performanceHigh": "高", + "performanceMedium": "中", + "performanceLow": "低" + } }, "accessRestrictions": { "title": "存取限制", diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 88b9741c7..4bb384e53 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -1,9 +1,11 @@ "use server"; +import { fromZonedTime } from "date-fns-tz"; import { and, eq, gte, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; +import { getEnvConfig } from "@/lib/config"; import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit/service"; import type { DailyResetMode } from "@/lib/rate-limit/time-utils"; @@ -23,6 +25,26 @@ import { import type { BillingModelSource } from "@/types/system-config"; import type { ActionResult } from "./types"; +/** + * Parse date range strings to timestamps using server timezone (TZ config). + * Returns startTime as midnight and endTime as next day midnight (exclusive upper bound). + */ +function parseDateRangeInServerTimezone( + startDate?: string, + endDate?: string +): { startTime?: number; endTime?: number } { + const timezone = getEnvConfig().TZ; + const parsedStart = startDate + ? fromZonedTime(`${startDate}T00:00:00`, timezone).getTime() + : Number.NaN; + const parsedEnd = endDate ? fromZonedTime(`${endDate}T00:00:00`, timezone).getTime() : Number.NaN; + + return { + startTime: Number.isFinite(parsedStart) ? parsedStart : undefined, + endTime: Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined, + }; +} + export interface MyUsageMetadata { keyName: string; keyProviderGroup: string | null; @@ -395,16 +417,10 @@ export async function getMyUsageLogs( const pageSize = Math.min(rawPageSize, 100); const page = filters.page && filters.page > 0 ? filters.page : 1; - const parsedStart = filters.startDate - ? new Date(`${filters.startDate}T00:00:00`).getTime() - : Number.NaN; - const parsedEnd = filters.endDate - ? new Date(`${filters.endDate}T00:00:00`).getTime() - : Number.NaN; - - const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined; - // endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题 - const endTime = Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined; + const { startTime, endTime } = parseDateRangeInServerTimezone( + filters.startDate, + filters.endDate + ); const usageFilters: UsageLogFilters = { keyId: session.key.id, @@ -519,6 +535,8 @@ export interface ModelBreakdownItem { cost: number; inputTokens: number; outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; } export interface MyStatsSummary extends UsageLogSummary { @@ -541,16 +559,10 @@ export async function getMyStatsSummary( const settings = await getSystemSettings(); const currencyCode = settings.currencyDisplay; - // 日期字符串来自前端的 YYYY-MM-DD(目前使用 toISOString().split("T")[0] 生成),因此按 UTC 解析更一致。 - // 注意:new Date("YYYY-MM-DDT00:00:00") 会按本地时区解析,可能导致跨时区边界偏移。 - const parsedStart = filters.startDate - ? Date.parse(`${filters.startDate}T00:00:00.000Z`) - : Number.NaN; - const parsedEnd = filters.endDate ? Date.parse(`${filters.endDate}T00:00:00.000Z`) : Number.NaN; - - const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined; - // endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题 - const endTime = Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined; + const { startTime, endTime } = parseDateRangeInServerTimezone( + filters.startDate, + filters.endDate + ); // Get aggregated stats using existing repository function const stats = await findUsageLogsStats({ @@ -567,6 +579,8 @@ export async function getMyStatsSummary( cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, + cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`, + cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`, }) .from(messageRequest) .where( @@ -589,6 +603,8 @@ export async function getMyStatsSummary( cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, + cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`, + cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`, }) .from(messageRequest) .where( @@ -611,6 +627,8 @@ export async function getMyStatsSummary( cost: Number(row.cost ?? 0), inputTokens: row.inputTokens, outputTokens: row.outputTokens, + cacheCreationTokens: row.cacheCreationTokens, + cacheReadTokens: row.cacheReadTokens, })), userModelBreakdown: userBreakdown.map((row) => ({ model: row.model, @@ -618,6 +636,8 @@ export async function getMyStatsSummary( cost: Number(row.cost ?? 0), inputTokens: row.inputTokens, outputTokens: row.outputTokens, + cacheCreationTokens: row.cacheCreationTokens, + cacheReadTokens: row.cacheReadTokens, })), currencyCode, }; diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index c95c6cd4b..6fd4121b6 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -299,9 +299,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { { header: t("columns.totalTokens"), className: "text-right", - cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalTokens), - sortKey: "totalTokens", - getValue: (row) => (row as ProviderCacheHitRateEntry).totalTokens, + cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalInputTokens), + sortKey: "totalInputTokens", + getValue: (row) => (row as ProviderCacheHitRateEntry).totalInputTokens, }, ]; diff --git a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx index a8cce1838..ee7e0db50 100644 --- a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx +++ b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx @@ -14,8 +14,6 @@ interface CollapsibleQuotaCardProps { quota: MyUsageQuota | null; loading?: boolean; currencyCode?: CurrencyCode; - keyExpiresAt?: Date | null; - userExpiresAt?: Date | null; defaultOpen?: boolean; } @@ -23,8 +21,6 @@ export function CollapsibleQuotaCard({ quota, loading = false, currencyCode = "USD", - keyExpiresAt, - userExpiresAt, defaultOpen = false, }: CollapsibleQuotaCardProps) { const [isOpen, setIsOpen] = useState(defaultOpen); @@ -164,13 +160,7 @@ export function CollapsibleQuotaCard({
- +
diff --git a/src/app/[locale]/my-usage/_components/expiration-info.tsx b/src/app/[locale]/my-usage/_components/expiration-info.tsx index 5f75f7837..51757bd3a 100644 --- a/src/app/[locale]/my-usage/_components/expiration-info.tsx +++ b/src/app/[locale]/my-usage/_components/expiration-info.tsx @@ -1,7 +1,7 @@ "use client"; +import { Clock } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; -import { QuotaCountdownCompact } from "@/components/quota/quota-countdown"; import { useCountdown } from "@/hooks/useCountdown"; import { cn } from "@/lib/utils"; import { formatDate, getLocaleDateFormat } from "@/lib/utils/date-format"; @@ -56,6 +56,14 @@ export function ExpirationInfo({ expired: "text-destructive", }; + const countdownStyles: Record = { + none: "text-muted-foreground", + normal: "text-emerald-600 dark:text-emerald-400", + warning: "text-amber-600 dark:text-amber-400", + danger: "text-red-600 dark:text-red-400", + expired: "text-destructive", + }; + const renderItem = ( label: string, value: Date | null, @@ -79,9 +87,11 @@ export function ExpirationInfo({ {showCountdown ? ( -
- {t("expiresIn", { time: countdown.shortFormatted })} - +
+ + + {countdown.shortFormatted} +
) : null}
diff --git a/src/app/[locale]/my-usage/_components/my-usage-header.tsx b/src/app/[locale]/my-usage/_components/my-usage-header.tsx index b3258e2c1..2acee5325 100644 --- a/src/app/[locale]/my-usage/_components/my-usage-header.tsx +++ b/src/app/[locale]/my-usage/_components/my-usage-header.tsx @@ -2,60 +2,19 @@ import { LogOut } from "lucide-react"; import { useTranslations } from "next-intl"; -import { QuotaCountdownCompact } from "@/components/quota/quota-countdown"; import { Button } from "@/components/ui/button"; -import { useCountdown } from "@/hooks/useCountdown"; import { useRouter } from "@/i18n/routing"; -import { cn } from "@/lib/utils"; interface MyUsageHeaderProps { onLogout?: () => Promise | void; keyName?: string; userName?: string; - keyExpiresAt?: Date | null; - userExpiresAt?: Date | null; } -export function MyUsageHeader({ - onLogout, - keyName, - userName, - keyExpiresAt, - userExpiresAt, -}: MyUsageHeaderProps) { +export function MyUsageHeader({ onLogout, keyName, userName }: MyUsageHeaderProps) { const t = useTranslations("myUsage.header"); - const tExpiration = useTranslations("myUsage.expiration"); const router = useRouter(); - const keyCountdown = useCountdown(keyExpiresAt ?? null, Boolean(keyExpiresAt)); - const userCountdown = useCountdown(userExpiresAt ?? null, Boolean(userExpiresAt)); - - const renderCountdownChip = ( - label: string, - expiresAt: Date | null | undefined, - countdown: ReturnType - ) => { - if (!expiresAt || countdown.isExpired || countdown.totalSeconds > 7 * 24 * 60 * 60) return null; - - const tone = countdown.totalSeconds <= 24 * 60 * 60 ? "danger" : "warning"; - const toneClass = - tone === "danger" - ? "bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-200" - : "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100"; - - return ( - - {label} - - - ); - }; - const handleLogout = async () => { if (onLogout) { await onLogout(); @@ -74,8 +33,6 @@ export function MyUsageHeader({

{userName ? t("welcome", { name: userName }) : t("title")}

- {renderCountdownChip(tExpiration("keyExpires"), keyExpiresAt, keyCountdown)} - {renderCountdownChip(tExpiration("userExpires"), userExpiresAt, userCountdown)}
diff --git a/src/app/[locale]/my-usage/_components/quota-cards.tsx b/src/app/[locale]/my-usage/_components/quota-cards.tsx index d1d3b6f4d..68a8a3a30 100644 --- a/src/app/[locale]/my-usage/_components/quota-cards.tsx +++ b/src/app/[locale]/my-usage/_components/quota-cards.tsx @@ -3,11 +3,9 @@ import { useTranslations } from "next-intl"; import { useMemo } from "react"; import type { MyUsageQuota } from "@/actions/my-usage"; -import { QuotaCountdownCompact } from "@/components/quota/quota-countdown"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; -import { useCountdown } from "@/hooks/useCountdown"; import type { CurrencyCode } from "@/lib/utils"; import { cn } from "@/lib/utils"; import { calculateUsagePercent, isUnlimited } from "@/lib/utils/limit-helpers"; @@ -16,67 +14,12 @@ interface QuotaCardsProps { quota: MyUsageQuota | null; loading?: boolean; currencyCode?: CurrencyCode; - keyExpiresAt?: Date | null; - userExpiresAt?: Date | null; } -export function QuotaCards({ - quota, - loading = false, - currencyCode = "USD", - keyExpiresAt, - userExpiresAt, -}: QuotaCardsProps) { +export function QuotaCards({ quota, loading = false, currencyCode = "USD" }: QuotaCardsProps) { const t = useTranslations("myUsage.quota"); - const tExpiration = useTranslations("myUsage.expiration"); const tCommon = useTranslations("common"); - const resolvedKeyExpires = keyExpiresAt ?? quota?.expiresAt ?? null; - const resolvedUserExpires = userExpiresAt ?? quota?.userExpiresAt ?? null; - - const shouldEnableCountdown = !(loading && !quota); - - const keyCountdown = useCountdown( - resolvedKeyExpires, - shouldEnableCountdown && Boolean(resolvedKeyExpires) - ); - const userCountdown = useCountdown( - resolvedUserExpires, - shouldEnableCountdown && Boolean(resolvedUserExpires) - ); - - const isExpiring = (countdown: ReturnType) => - countdown.totalSeconds > 0 && countdown.totalSeconds <= 7 * 24 * 60 * 60; - - const showKeyBadge = resolvedKeyExpires && !keyCountdown.isExpired && isExpiring(keyCountdown); - const showUserBadge = - resolvedUserExpires && !userCountdown.isExpired && isExpiring(userCountdown); - - const renderExpireBadge = ( - label: string, - resetAt: Date | null, - countdown: ReturnType - ) => { - if (!resetAt) return null; - const tone = countdown.totalSeconds <= 24 * 60 * 60 ? "danger" : "warning"; - const toneClass = - tone === "danger" - ? "bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-200" - : "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100"; - - return ( - - {label} - - - ); - }; - const items = useMemo(() => { if (!quota) return []; return [ @@ -137,20 +80,6 @@ export function QuotaCards({ return (
- {showKeyBadge || showUserBadge ? ( -
- - {tExpiration("expiringWarning")} - - {showKeyBadge - ? renderExpireBadge(tExpiration("keyExpires"), resolvedKeyExpires, keyCountdown) - : null} - {showUserBadge - ? renderExpireBadge(tExpiration("userExpires"), resolvedUserExpires, userCountdown) - : null} -
- ) : null} -
{items.map((item) => { const keyPct = calculateUsagePercent(item.keyCurrent, item.keyLimit); diff --git a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx index 2a848cfdf..a17af0415 100644 --- a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx +++ b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx @@ -1,11 +1,24 @@ "use client"; -import { BarChart3, RefreshCw } from "lucide-react"; +import { format } from "date-fns"; +import { + Activity, + ArrowDownRight, + ArrowUpRight, + BarChart3, + Coins, + Database, + Hash, + Percent, + RefreshCw, + Target, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; import { getMyStatsSummary, type MyStatsSummary } from "@/actions/my-usage"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { formatTokenAmount } from "@/lib/utils"; @@ -26,7 +39,7 @@ export function StatisticsSummaryCard({ const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [dateRange, setDateRange] = useState<{ startDate?: string; endDate?: string }>(() => { - const today = new Date().toISOString().split("T")[0]; + const today = format(new Date(), "yyyy-MM-dd"); return { startDate: today, endDate: today }; }); const intervalRef = useRef(null); @@ -219,7 +232,10 @@ export function StatisticsSummaryCard({ cost={item.cost} inputTokens={item.inputTokens} outputTokens={item.outputTokens} + cacheCreationTokens={item.cacheCreationTokens} + cacheReadTokens={item.cacheReadTokens} currencyCode={currencyCode} + totalCost={stats.totalCost} /> ))}
@@ -243,7 +259,10 @@ export function StatisticsSummaryCard({ cost={item.cost} inputTokens={item.inputTokens} outputTokens={item.outputTokens} + cacheCreationTokens={item.cacheCreationTokens} + cacheReadTokens={item.cacheReadTokens} currencyCode={currencyCode} + totalCost={stats.totalCost} /> ))}
@@ -268,7 +287,10 @@ interface ModelBreakdownRowProps { cost: number; inputTokens: number; outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; currencyCode: CurrencyCode; + totalCost: number; } function ModelBreakdownRow({ @@ -277,21 +299,196 @@ function ModelBreakdownRow({ cost, inputTokens, outputTokens, + cacheCreationTokens, + cacheReadTokens, currencyCode, + totalCost, }: ModelBreakdownRowProps) { + const [open, setOpen] = useState(false); const t = useTranslations("myUsage.stats"); + const totalAllTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens; + const cacheHitRate = + totalInputTokens > 0 ? ((cacheReadTokens / totalInputTokens) * 100).toFixed(1) : "0.0"; + const costPercentage = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : "0.0"; + + const cacheHitRateNum = Number.parseFloat(cacheHitRate); + const cacheHitColor = + cacheHitRateNum >= 85 + ? "text-green-600 dark:text-green-400" + : cacheHitRateNum >= 60 + ? "text-yellow-600 dark:text-yellow-400" + : "text-orange-600 dark:text-orange-400"; + return ( -
-
- {model || t("unknownModel")} - - {requests.toLocaleString()} req · {formatTokenAmount(inputTokens + outputTokens)} tok - -
-
- {formatCurrency(cost, currencyCode)} + <> +
setOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setOpen(true); + } + }} + > +
+ {model || t("unknownModel")} +
+ + + {requests.toLocaleString()} + + + + {formatTokenAmount(totalAllTokens)} + + + + {cacheHitRate}% + +
+
+
+
{formatCurrency(cost, currencyCode)}
+
({costPercentage}%)
+
-
+ + + + + + + {model || t("unknownModel")} + + +
+
+
+
+ + {t("modal.requests")} +
+
{requests.toLocaleString()}
+
+ +
+
+ + {t("modal.totalTokens")} +
+
+ {formatTokenAmount(totalAllTokens)} +
+
+ +
+
+ + {t("modal.cost")} +
+
+ {formatCurrency(cost, currencyCode)} +
+
+
+ + + +
+

+ + {t("modal.totalTokens")} +

+
+
+
+ + {t("modal.inputTokens")} +
+
+ {formatTokenAmount(inputTokens)} +
+
+ +
+
+ + {t("modal.outputTokens")} +
+
+ {formatTokenAmount(outputTokens)} +
+
+
+
+ + + +
+

+ + {t("modal.cacheTokens")} +

+
+
+
+ + {t("modal.cacheWrite")} +
+
+ {formatTokenAmount(cacheCreationTokens)} +
+
+ +
+
+ + {t("modal.cacheRead")} +
+
+ {formatTokenAmount(cacheReadTokens)} +
+
+
+ +
+
+
+ + {t("modal.cacheHitRate")} +
+
+ + {cacheHitRate}% + + = 85 + ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + : cacheHitRateNum >= 60 + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + : "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" + }`} + > + + {cacheHitRateNum >= 85 + ? t("modal.performanceHigh") + : cacheHitRateNum >= 60 + ? t("modal.performanceMedium") + : t("modal.performanceLow")} + +
+
+
+
+
+
+
+ ); } diff --git a/src/app/[locale]/my-usage/page.tsx b/src/app/[locale]/my-usage/page.tsx index 6d7e6c4b0..366adc893 100644 --- a/src/app/[locale]/my-usage/page.tsx +++ b/src/app/[locale]/my-usage/page.tsx @@ -55,13 +55,7 @@ export default function MyUsagePage() { return (
- + {/* Provider Group and Expiration info */} {quota ? ( @@ -80,12 +74,7 @@ export default function MyUsagePage() {
) : null} - + diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 395d1a7ef..0e96bf0d3 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -53,6 +53,9 @@ export interface ProviderCacheHitRateLeaderboardEntry { cacheReadTokens: number; totalCost: number; cacheCreationCost: number; + /** Input tokens only (input + cacheCreation + cacheRead) for cache hit rate denominator */ + totalInputTokens: number; + /** @deprecated Use totalInputTokens instead */ totalTokens: number; cacheHitRate: number; // 0-1 之间的小数,UI 层负责格式化为百分比 } @@ -427,7 +430,7 @@ async function findProviderLeaderboardWithTimezone( * * 计算规则: * - 仅统计需要缓存的请求(cache_creation_input_tokens 与 cache_read_input_tokens 不同时为 0/null) - * - 命中率 = cache_read / (input + output + cache_creation + cache_read) + * - 命中率 = cache_read / (input + cache_creation + cache_read) */ async function findProviderCacheHitRateLeaderboardWithTimezone( period: LeaderboardPeriod, @@ -435,9 +438,8 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( dateRange?: DateRangeParams, providerType?: ProviderType ): Promise { - const totalTokensExpr = sql`( + const totalInputTokensExpr = sql`( COALESCE(${messageRequest.inputTokens}, 0) + - COALESCE(${messageRequest.outputTokens}, 0) + COALESCE(${messageRequest.cacheCreationInputTokens}, 0) + COALESCE(${messageRequest.cacheReadInputTokens}, 0) )`; @@ -447,12 +449,12 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( OR COALESCE(${messageRequest.cacheReadInputTokens}, 0) > 0 )`; - const sumTotalTokens = sql`COALESCE(sum(${totalTokensExpr})::double precision, 0::double precision)`; + const sumTotalInputTokens = sql`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`; const sumCacheReadTokens = sql`COALESCE(sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; const sumCacheCreationCost = sql`COALESCE(sum(CASE WHEN COALESCE(${messageRequest.cacheCreationInputTokens}, 0) > 0 THEN ${messageRequest.costUsd} ELSE 0 END), 0)`; const cacheHitRateExpr = sql`COALESCE( - ${sumCacheReadTokens} / NULLIF(${sumTotalTokens}, 0::double precision), + ${sumCacheReadTokens} / NULLIF(${sumTotalInputTokens}, 0::double precision), 0::double precision )`; @@ -472,7 +474,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, cacheReadTokens: sumCacheReadTokens, cacheCreationCost: sumCacheCreationCost, - totalTokens: sumTotalTokens, + totalInputTokens: sumTotalInputTokens, cacheHitRate: cacheHitRateExpr, }) .from(messageRequest) @@ -493,7 +495,8 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( totalCost: parseFloat(entry.totalCost), cacheReadTokens: entry.cacheReadTokens, cacheCreationCost: parseFloat(entry.cacheCreationCost), - totalTokens: entry.totalTokens, + totalInputTokens: entry.totalInputTokens, + totalTokens: entry.totalInputTokens, // deprecated, for backward compatibility cacheHitRate: Math.min(Math.max(entry.cacheHitRate ?? 0, 0), 1), })); }