Просмотр исходного кода

feat(my-usage): cache statistics and timezone fixes (#623)

* feat(my-usage): add cache token statistics to model breakdown

Add cacheCreationTokens and cacheReadTokens fields to ModelBreakdownItem
interface and related DB queries for displaying cache statistics in the
statistics summary card modal.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(my-usage): use configured timezone for date filtering

Use server timezone (TZ config) instead of browser locale when:
- Filtering statistics by date (getMyStatsSummary)
- Calculating daily quota time ranges (getTimeRangeForPeriod)

This ensures consistent date interpretation across different client
timezones. Fixes discrepancy between Daily Quota and Statistics
Summary when user is in a different timezone than the server.

Added comprehensive unit tests covering:
- Date parsing in configured timezone
- Timezone offset calculations
- Day boundary edge cases

* refactor(my-usage): clean up expiration displays

Remove duplicate expiration information:
- Remove Key Expires chip from Welcome header (keep User Expires only)
- Remove "Expiring Soon" warning block from Quota Usage cards

Improve countdown display in ExpirationInfo component:
- Add Clock icon
- Increase font size to match date display
- Use monospace font for better readability
- Color-coded by status (emerald/amber/red)

ExpirationInfo remains the single source of expiration data.

* fix: correct cache hit rate formula to exclude output tokens

Output tokens are never cached (per Anthropic docs), so they should not be
included in cache hit rate calculation. Changed formula from:
  cacheReadTokens / (input + output + cacheCreate + cacheRead)
to:
  cacheReadTokens / (input + cacheCreate + cacheRead)

Affects:
- /my-usage model breakdown cache hit rate display
- /dashboard/leaderboard provider cache hit rate ranking

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>

* fix: address PR #623 review feedback

- Fix outdated cache hit rate comment (leaderboard.ts:430)
- Add keyboard accessibility to model breakdown rows (a11y)
- Rename totalTokens -> totalInputTokens in ProviderCacheHitRateLeaderboardEntry
- Extract parseDateRangeInServerTimezone helper to reduce duplication

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: add backward compatibility for totalTokens field

Keep returning totalTokens (deprecated) alongside totalInputTokens
for API consumers that haven't migrated yet.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: John Doe <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
miraserver 3 недель назад
Родитель
Сommit
745df9b05e

+ 16 - 1
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",

+ 16 - 1
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": "アクセス制限",

+ 16 - 1
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": "Ограничения доступа",

+ 16 - 1
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": "访问限制",

+ 16 - 1
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": "存取限制",

+ 40 - 20
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<string>`COALESCE(sum(${messageRequest.costUsd}), 0)`,
         inputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
         outputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
+        cacheCreationTokens: sql<number>`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`,
+        cacheReadTokens: sql<number>`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`,
       })
       .from(messageRequest)
       .where(
@@ -589,6 +603,8 @@ export async function getMyStatsSummary(
         cost: sql<string>`COALESCE(sum(${messageRequest.costUsd}), 0)`,
         inputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
         outputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
+        cacheCreationTokens: sql<number>`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`,
+        cacheReadTokens: sql<number>`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,
     };

+ 3 - 3
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,
     },
   ];
 

+ 1 - 11
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({
 
         <CollapsibleContent>
           <div className="p-4">
-            <QuotaCards
-              quota={quota}
-              loading={loading}
-              currencyCode={currencyCode}
-              keyExpiresAt={keyExpiresAt}
-              userExpiresAt={userExpiresAt}
-            />
+            <QuotaCards quota={quota} loading={loading} currencyCode={currencyCode} />
           </div>
         </CollapsibleContent>
       </div>

+ 14 - 4
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<ExpireStatus, string> = {
+    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({
           </span>
         </div>
         {showCountdown ? (
-          <div className="flex items-center gap-2 text-xs text-muted-foreground">
-            <span>{t("expiresIn", { time: countdown.shortFormatted })}</span>
-            <QuotaCountdownCompact resetAt={value} />
+          <div className={cn("flex items-center gap-1.5 pt-1", countdownStyles[status])}>
+            <Clock className="h-3.5 w-3.5" />
+            <span className="text-sm font-semibold font-mono tabular-nums">
+              {countdown.shortFormatted}
+            </span>
           </div>
         ) : null}
       </div>

+ 1 - 44
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> | 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<typeof useCountdown>
-  ) => {
-    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 (
-      <span
-        className={cn(
-          "inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium",
-          toneClass
-        )}
-      >
-        <span>{label}</span>
-        <QuotaCountdownCompact resetAt={expiresAt} />
-      </span>
-    );
-  };
-
   const handleLogout = async () => {
     if (onLogout) {
       await onLogout();
@@ -74,8 +33,6 @@ export function MyUsageHeader({
           <h1 className="text-xl font-semibold leading-tight">
             {userName ? t("welcome", { name: userName }) : t("title")}
           </h1>
-          {renderCountdownChip(tExpiration("keyExpires"), keyExpiresAt, keyCountdown)}
-          {renderCountdownChip(tExpiration("userExpires"), userExpiresAt, userCountdown)}
         </div>
         <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
           <span className="flex items-center gap-1">

+ 1 - 72
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<typeof useCountdown>) =>
-    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<typeof useCountdown>
-  ) => {
-    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 (
-      <span
-        className={cn(
-          "inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium",
-          toneClass
-        )}
-      >
-        <span>{label}</span>
-        <QuotaCountdownCompact resetAt={resetAt} />
-      </span>
-    );
-  };
-
   const items = useMemo(() => {
     if (!quota) return [];
     return [
@@ -137,20 +80,6 @@ export function QuotaCards({
 
   return (
     <div className="space-y-3">
-      {showKeyBadge || showUserBadge ? (
-        <div className="flex flex-wrap items-center gap-2 rounded-lg border border-dashed bg-muted/40 p-3">
-          <span className="text-xs font-medium text-muted-foreground">
-            {tExpiration("expiringWarning")}
-          </span>
-          {showKeyBadge
-            ? renderExpireBadge(tExpiration("keyExpires"), resolvedKeyExpires, keyCountdown)
-            : null}
-          {showUserBadge
-            ? renderExpireBadge(tExpiration("userExpires"), resolvedUserExpires, userCountdown)
-            : null}
-        </div>
-      ) : null}
-
       <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
         {items.map((item) => {
           const keyPct = calculateUsagePercent(item.keyCurrent, item.keyLimit);

+ 209 - 12
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<NodeJS.Timeout | null>(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}
                         />
                       ))}
                     </div>
@@ -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}
                         />
                       ))}
                     </div>
@@ -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 (
-    <div className="flex items-center justify-between rounded-md border px-3 py-2">
-      <div className="flex flex-col text-sm min-w-0">
-        <span className="font-medium text-foreground truncate">{model || t("unknownModel")}</span>
-        <span className="text-xs text-muted-foreground">
-          {requests.toLocaleString()} req · {formatTokenAmount(inputTokens + outputTokens)} tok
-        </span>
-      </div>
-      <div className="text-right text-sm font-semibold text-foreground whitespace-nowrap ml-2">
-        {formatCurrency(cost, currencyCode)}
+    <>
+      <div
+        role="button"
+        tabIndex={0}
+        className="flex items-center justify-between rounded-md border px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors group"
+        onClick={() => setOpen(true)}
+        onKeyDown={(e) => {
+          if (e.key === "Enter" || e.key === " ") {
+            e.preventDefault();
+            setOpen(true);
+          }
+        }}
+      >
+        <div className="flex flex-col text-sm min-w-0 gap-1">
+          <span className="font-medium text-foreground truncate">{model || t("unknownModel")}</span>
+          <div className="flex items-center gap-3 text-xs text-muted-foreground">
+            <span className="flex items-center gap-1">
+              <Activity className="h-3 w-3" />
+              {requests.toLocaleString()}
+            </span>
+            <span className="flex items-center gap-1">
+              <Hash className="h-3 w-3" />
+              {formatTokenAmount(totalAllTokens)}
+            </span>
+            <span className={`flex items-center gap-1 ${cacheHitColor}`}>
+              <Target className="h-3 w-3" />
+              {cacheHitRate}%
+            </span>
+          </div>
+        </div>
+        <div className="text-right text-sm font-semibold text-foreground whitespace-nowrap ml-2">
+          <div>{formatCurrency(cost, currencyCode)}</div>
+          <div className="text-xs text-muted-foreground font-normal">({costPercentage}%)</div>
+        </div>
       </div>
-    </div>
+
+      <Dialog open={open} onOpenChange={setOpen}>
+        <DialogContent className="sm:max-w-lg">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2 text-lg">
+              <Database className="h-5 w-5 text-primary" />
+              {model || t("unknownModel")}
+            </DialogTitle>
+          </DialogHeader>
+          <div className="space-y-4">
+            <div className="grid grid-cols-3 gap-3">
+              <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                  <Activity className="h-3.5 w-3.5" />
+                  {t("modal.requests")}
+                </div>
+                <div className="text-lg font-semibold font-mono">{requests.toLocaleString()}</div>
+              </div>
+
+              <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                  <Hash className="h-3.5 w-3.5" />
+                  {t("modal.totalTokens")}
+                </div>
+                <div className="text-lg font-semibold font-mono">
+                  {formatTokenAmount(totalAllTokens)}
+                </div>
+              </div>
+
+              <div className="rounded-lg border bg-muted/50 p-3 space-y-1">
+                <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                  <Coins className="h-3.5 w-3.5" />
+                  {t("modal.cost")}
+                </div>
+                <div className="text-lg font-semibold font-mono">
+                  {formatCurrency(cost, currencyCode)}
+                </div>
+              </div>
+            </div>
+
+            <Separator />
+
+            <div className="space-y-2">
+              <h4 className="text-sm font-medium flex items-center gap-1.5">
+                <Hash className="h-4 w-4 text-muted-foreground" />
+                {t("modal.totalTokens")}
+              </h4>
+              <div className="grid grid-cols-2 gap-3">
+                <div className="rounded-lg border p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <ArrowUpRight className="h-3.5 w-3.5 text-blue-500" />
+                    {t("modal.inputTokens")}
+                  </div>
+                  <div className="text-base font-semibold font-mono">
+                    {formatTokenAmount(inputTokens)}
+                  </div>
+                </div>
+
+                <div className="rounded-lg border p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <ArrowDownRight className="h-3.5 w-3.5 text-purple-500" />
+                    {t("modal.outputTokens")}
+                  </div>
+                  <div className="text-base font-semibold font-mono">
+                    {formatTokenAmount(outputTokens)}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <Separator />
+
+            <div className="space-y-2">
+              <h4 className="text-sm font-medium flex items-center gap-1.5">
+                <Database className="h-4 w-4 text-muted-foreground" />
+                {t("modal.cacheTokens")}
+              </h4>
+              <div className="grid grid-cols-2 gap-3">
+                <div className="rounded-lg border p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <Database className="h-3.5 w-3.5 text-orange-500" />
+                    {t("modal.cacheWrite")}
+                  </div>
+                  <div className="text-base font-semibold font-mono">
+                    {formatTokenAmount(cacheCreationTokens)}
+                  </div>
+                </div>
+
+                <div className="rounded-lg border p-3 space-y-1">
+                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <Database className="h-3.5 w-3.5 text-green-500" />
+                    {t("modal.cacheRead")}
+                  </div>
+                  <div className="text-base font-semibold font-mono">
+                    {formatTokenAmount(cacheReadTokens)}
+                  </div>
+                </div>
+              </div>
+
+              <div className="rounded-lg border bg-gradient-to-r from-muted/50 to-muted/30 p-3 mt-2">
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-1.5 text-sm font-medium">
+                    <Target className="h-4 w-4" />
+                    {t("modal.cacheHitRate")}
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <span className={`text-lg font-bold font-mono ${cacheHitColor}`}>
+                      {cacheHitRate}%
+                    </span>
+                    <span
+                      className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
+                        cacheHitRateNum >= 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"
+                      }`}
+                    >
+                      <Percent className="h-3 w-3" />
+                      {cacheHitRateNum >= 85
+                        ? t("modal.performanceHigh")
+                        : cacheHitRateNum >= 60
+                          ? t("modal.performanceMedium")
+                          : t("modal.performanceLow")}
+                    </span>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </DialogContent>
+      </Dialog>
+    </>
   );
 }

+ 2 - 13
src/app/[locale]/my-usage/page.tsx

@@ -55,13 +55,7 @@ export default function MyUsagePage() {
 
   return (
     <div className="space-y-6">
-      <MyUsageHeader
-        onLogout={handleLogout}
-        keyName={quota?.keyName}
-        userName={quota?.userName}
-        keyExpiresAt={keyExpiresAt}
-        userExpiresAt={userExpiresAt}
-      />
+      <MyUsageHeader onLogout={handleLogout} keyName={quota?.keyName} userName={quota?.userName} />
 
       {/* Provider Group and Expiration info */}
       {quota ? (
@@ -80,12 +74,7 @@ export default function MyUsagePage() {
         </div>
       ) : null}
 
-      <CollapsibleQuotaCard
-        quota={quota}
-        loading={isQuotaLoading}
-        keyExpiresAt={keyExpiresAt}
-        userExpiresAt={userExpiresAt}
-      />
+      <CollapsibleQuotaCard quota={quota} loading={isQuotaLoading} />
 
       <StatisticsSummaryCard />
 

+ 10 - 7
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<ProviderCacheHitRateLeaderboardEntry[]> {
-  const totalTokensExpr = sql<number>`(
+  const totalInputTokensExpr = sql<number>`(
     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<number>`COALESCE(sum(${totalTokensExpr})::double precision, 0::double precision)`;
+  const sumTotalInputTokens = sql<number>`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`;
   const sumCacheReadTokens = sql<number>`COALESCE(sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0))::double precision, 0::double precision)`;
   const sumCacheCreationCost = sql<string>`COALESCE(sum(CASE WHEN COALESCE(${messageRequest.cacheCreationInputTokens}, 0) > 0 THEN ${messageRequest.costUsd} ELSE 0 END), 0)`;
 
   const cacheHitRateExpr = sql<number>`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<string>`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),
   }));
 }