diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 5ed3ad430..752ed7c37 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "Cache-eligible Requests", "cacheHitRate": "Cache Hit Rate", "cacheCoefficient": "Cache Coefficient", + "cacheCoefficientTooltip": "A higher cache coefficient means fewer provider account switches and less noticeable cache degradation. 0.9+ is excellent, 0.8+ is good.", "cacheReadTokens": "Cache Read Tokens", "totalTokens": "Total Tokens", "cacheCreationConsumedAmount": "Cache Creation Spend", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 5bfb26738..0f49373bb 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "キャッシュ対象リクエスト数(命中率計算対象)", "cacheHitRate": "キャッシュ命中率", "cacheCoefficient": "キャッシュ係数", + "cacheCoefficientTooltip": "キャッシュ係数が大きいほど、プロバイダーのアカウント切り替えが少なく、キャッシュ劣化が目立ちにくくなります。0.9 以上は優秀、0.8 以上は良好です。", "cacheReadTokens": "キャッシュ読取トークン数", "totalTokens": "総トークン数", "cacheCreationConsumedAmount": "キャッシュ作成消費額", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 453a0e427..5b630c199 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "Запросы (учтены в hit rate)", "cacheHitRate": "Попадания в кэш", "cacheCoefficient": "Коэффициент кэша", + "cacheCoefficientTooltip": "Чем выше коэффициент кэша, тем реже переключаются аккаунты поставщика и тем менее заметна деградация кэша. 0.9 и выше — отлично, 0.8 и выше — хорошо.", "cacheReadTokens": "Токены чтения из кэша", "totalTokens": "Всего токенов", "cacheCreationConsumedAmount": "Расход на создание кэша", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 9b1d50786..5b07a9346 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "缓存触发请求数", "cacheHitRate": "缓存命中率", "cacheCoefficient": "缓存系数", + "cacheCoefficientTooltip": "缓存系数越大,供应商切号越少,失缓现象越不明显。0.9 以上为优秀,0.8 以上为良好。", "cacheReadTokens": "缓存读取 Token 数", "totalTokens": "总 Token 数", "cacheCreationConsumedAmount": "缓存创建消耗金额", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 112d7afde..b69873d4b 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -704,6 +704,7 @@ "cacheHitRequests": "快取命中請求數(納入快取命中率計算的請求總數)", "cacheHitRate": "快取命中率", "cacheCoefficient": "快取係數", + "cacheCoefficientTooltip": "快取係數越大,供應商切號越少,失緩現象越不明顯。0.9 以上為優秀,0.8 以上為良好。", "cacheReadTokens": "快取讀取 Token 數", "totalTokens": "總 Token 數", "cacheCreationConsumedAmount": "快取建立消耗金額", diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx index 2f8438eb9..ea995d44d 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx @@ -7,6 +7,7 @@ import { Award, ChevronDown, ChevronRight, + CircleHelp, Medal, Trophy, } from "lucide-react"; @@ -22,11 +23,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { LeaderboardPeriod } from "@/repository/leaderboard"; // 支持动态列定义 export interface ColumnDef { header: string; + /** 表头帮助图标悬停时展示的说明文案 */ + headerTooltip?: string; className?: string; /** * index 语义: @@ -245,6 +249,23 @@ export function LeaderboardTable({ className={`flex items-center ${col.className?.includes("text-right") ? "justify-end" : ""} ${shouldBold ? "font-bold" : ""}`} > {col.header} + {col.headerTooltip && ( + + + + + + {col.headerTooltip} + + + )} {col.sortKey && getSortIcon(col.sortKey)} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 060d519f5..87aa57966 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -101,6 +101,21 @@ function renderSuccessRateCell( const VALID_PERIODS: LeaderboardPeriod[] = ["daily", "weekly", "monthly", "allTime", "custom"]; +// 缓存系数分层配色(与缓存命中率同档):>=0.9 优秀(绿),>=0.8 良好(黄),其余橙色 +function renderCacheCoefficientCell(bp: number | null) { + if (bp == null) { + return ; + } + const value = bp / 10000; + const colorClass = + value >= 0.9 + ? "text-green-600 dark:text-green-400" + : value >= 0.8 + ? "text-yellow-600 dark:text-yellow-400" + : "text-orange-600 dark:text-orange-400"; + return {value.toFixed(2)}; +} + export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { const t = useTranslations("dashboard.leaderboard"); const searchParams = useSearchParams(); @@ -385,11 +400,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, { header: t("columns.cacheCoefficient"), + headerTooltip: t("columns.cacheCoefficientTooltip"), className: "text-right", - cell: (row) => { - const bp = "cacheCoefficientBp" in row ? row.cacheCoefficientBp : null; - return bp == null ? "–" : (bp / 10000).toFixed(2); - }, + cell: (row) => + renderCacheCoefficientCell("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), sortKey: "cacheCoefficientBp", getValue: (row) => ("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), }, @@ -430,11 +444,10 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { }, { header: t("columns.cacheCoefficient"), + headerTooltip: t("columns.cacheCoefficientTooltip"), className: "text-right", - cell: (row) => { - const bp = "cacheCoefficientBp" in row ? row.cacheCoefficientBp : null; - return bp == null ? "–" : (bp / 10000).toFixed(2); - }, + cell: (row) => + renderCacheCoefficientCell("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), sortKey: "cacheCoefficientBp", getValue: (row) => ("cacheCoefficientBp" in row ? row.cacheCoefficientBp : null), }, diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index 522a7a293..b20ea4a2f 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -349,7 +349,7 @@ export function SummaryTab({ {identity.value} diff --git a/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx b/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx index 860f11dca..8fb4d147e 100644 --- a/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx +++ b/tests/unit/dashboard/leaderboard-view-cache-coefficient.test.tsx @@ -124,6 +124,104 @@ describe("LeaderboardView cache coefficient column", () => { expect(text).toContain("–"); }); + it("shows a tooltip trigger on the cache coefficient column header", async () => { + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=providerCacheHitRate")) { + return { + ok: true, + json: async () => [cacheHitEntry({ providerId: 1, cacheCoefficientBp: 9000 })], + } as Response; + } + return { ok: true, json: async () => [] } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const coefficientHeader = Array.from(container!.querySelectorAll("th")).find((th) => + th.textContent?.includes("columns.cacheCoefficient") + ); + expect(coefficientHeader).toBeDefined(); + const trigger = coefficientHeader!.querySelector('[data-slot="tooltip-trigger"]'); + expect(trigger).not.toBeNull(); + }); + + it("does not trigger column sorting when the help icon is clicked", async () => { + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=providerCacheHitRate")) { + return { + ok: true, + json: async () => [ + cacheHitEntry({ providerId: 1, providerName: "high-first", cacheCoefficientBp: 9500 }), + cacheHitEntry({ providerId: 2, providerName: "low-second", cacheCoefficientBp: 5000 }), + ], + } as Response; + } + return { ok: true, json: async () => [] } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const coefficientHeader = Array.from(container!.querySelectorAll("th")).find((th) => + th.textContent?.includes("columns.cacheCoefficient") + ); + const trigger = coefficientHeader!.querySelector('[data-slot="tooltip-trigger"]'); + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // 行顺序保持默认(未触发升序排序,否则 low-second 会排到第一行) + const bodyText = container!.querySelector("tbody")?.textContent ?? ""; + expect(bodyText.indexOf("high-first")).toBeLessThan(bodyText.indexOf("low-second")); + }); + + it("colors the coefficient by tier: >=0.9 green, >=0.8 yellow, else orange", async () => { + fetchMock.mockImplementation(async (input) => { + const url = String(input); + if (url.includes("scope=providerCacheHitRate")) { + return { + ok: true, + json: async () => [ + cacheHitEntry({ providerId: 1, providerName: "excellent", cacheCoefficientBp: 9500 }), + cacheHitEntry({ + providerId: 2, + providerName: "edge-excellent", + cacheCoefficientBp: 9000, + }), + cacheHitEntry({ providerId: 3, providerName: "good", cacheCoefficientBp: 8600 }), + cacheHitEntry({ providerId: 4, providerName: "edge-good", cacheCoefficientBp: 8000 }), + cacheHitEntry({ providerId: 5, providerName: "poor", cacheCoefficientBp: 5000 }), + cacheHitEntry({ providerId: 6, providerName: "missing", cacheCoefficientBp: null }), + ], + } as Response; + } + return { ok: true, json: async () => [] } as Response; + }); + + await act(async () => { + root!.render(); + }); + + const hasColoredValue = (selector: string, text: string) => + Array.from(container!.querySelectorAll(selector)).some((el) => el.textContent === text); + expect(hasColoredValue("span.text-green-600", "0.95")).toBe(true); + // 边界值:0.90 仍属优秀档 + expect(hasColoredValue("span.text-green-600", "0.90")).toBe(true); + expect(hasColoredValue("span.text-yellow-600", "0.86")).toBe(true); + // 边界值:0.80 仍属良好档 + expect(hasColoredValue("span.text-yellow-600", "0.80")).toBe(true); + expect(hasColoredValue("span.text-orange-600", "0.50")).toBe(true); + // 缺失值:muted 样式展示占位符 + expect(hasColoredValue("span.text-muted-foreground", "–")).toBe(true); + }); + it("renders the coefficient column on the provider usage board too", async () => { searchParamsState.value = new URLSearchParams("scope=provider"); fetchMock.mockImplementation(async (input) => {