From 5e0ca6a5048acd3978d165fecf454e1ca4f97d4b Mon Sep 17 00:00:00 2001 From: shangzy Date: Fri, 8 May 2026 17:35:05 +0800 Subject: [PATCH] feat: add user token distribution tab --- app/detail/[...slug]/page.tsx | 229 ++++++++++++++++++++++++++++------ lib/i18n.tsx | 10 ++ 2 files changed, 202 insertions(+), 37 deletions(-) diff --git a/app/detail/[...slug]/page.tsx b/app/detail/[...slug]/page.tsx index 1fd821b..c01f351 100644 --- a/app/detail/[...slug]/page.tsx +++ b/app/detail/[...slug]/page.tsx @@ -1,15 +1,16 @@ "use client"; -import { useEffect, useState, useCallback, startTransition } from "react"; +import { Fragment, useEffect, useState, useCallback, startTransition } from "react"; import { useParams } from "next/navigation"; import { motion } from "motion/react"; -import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; +import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen, ArrowUpDown, ArrowDown, ArrowUp, ChevronRight, KeyRound } from "lucide-react"; import Link from "next/link"; import { StatsCard } from "@/components/StatsCard"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TrendChart } from "@/components/charts/TrendChart"; import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils"; import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "@/lib/detail-sort"; +import { getPrimaryModelNames, getSharePercent, getTokenDisplayName, getTokenRowKey, shouldShowTokenTab, sortTokenBreakdown, type TokenBreakdownItem } from "@/lib/token-breakdown"; import { getDetailStats, type DetailStatKey } from "@/lib/detail-stats"; import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; @@ -20,9 +21,12 @@ interface DetailData { total_tokens: number; quota: number; display_name?: string; models?: DetailBreakdownItem[]; users?: DetailBreakdownItem[]; + tokens?: TokenBreakdownItem[]; channel_name?: string; } +type BreakdownTab = "models" | "tokens"; + export default function DetailPage() { const { t } = useI18n(); const params = useParams(); @@ -37,6 +41,8 @@ export default function DetailPage() { const [loading, setLoading] = useState(true); const [breakdownSortKey, setBreakdownSortKey] = useState("total_tokens"); const [breakdownSortAsc, setBreakdownSortAsc] = useState(false); + const [breakdownTab, setBreakdownTab] = useState("models"); + const [expandedTokens, setExpandedTokens] = useState>(() => new Set()); const fetchData = useCallback(async () => { startTransition(() => setLoading(true)); @@ -57,8 +63,12 @@ export default function DetailPage() { const title = type === "channel" ? (data?.channel_name || decodedId) : (data?.display_name || decodedId); const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type; - const breakdownItems = data?.models || data?.users || []; - const sortedBreakdownItems = sortDetailBreakdown(breakdownItems, breakdownSortKey, breakdownSortAsc); + const modelBreakdownItems = data?.models || data?.users || []; + const tokenBreakdownItems = data?.tokens || []; + const showTokenTab = shouldShowTokenTab(type); + const activeBreakdownTab = showTokenTab ? breakdownTab : "models"; + const sortedBreakdownItems = sortDetailBreakdown(modelBreakdownItems, breakdownSortKey, breakdownSortAsc); + const sortedTokenItems = sortTokenBreakdown(tokenBreakdownItems, breakdownSortKey, breakdownSortAsc); const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist"); function handleBreakdownSort(key: DetailBreakdownSortKey) { @@ -66,6 +76,15 @@ export default function DetailPage() { else { setBreakdownSortKey(key); setBreakdownSortAsc(false); } } + function toggleToken(tokenName: string) { + setExpandedTokens((current) => { + const next = new Set(current); + if (next.has(tokenName)) next.delete(tokenName); + else next.add(tokenName); + return next; + }); + } + const renderBreakdownSortIcon = (col: DetailBreakdownSortKey) => { if (breakdownSortKey !== col) return ; return breakdownSortAsc @@ -90,6 +109,112 @@ export default function DetailPage() { cache_read_tokens: BookOpen, }; + function renderEmptyBreakdown() { + return ( +
+ {t("common.noData")} +
+ ); + } + + function renderTokenDistribution() { + if (sortedTokenItems.length === 0) return renderEmptyBreakdown(); + + return ( + + + + + {breakdownColumns.slice(1).map((col) => { + const key = col.key; + return ( + + ); + })} + + + + + + {sortedTokenItems.map((item) => { + const isExpanded = expandedTokens.has(item.name); + const tokenLabel = getTokenDisplayName(item.name, t("detail.unnamedToken")); + const userShare = getSharePercent(item.total_tokens, data?.total_tokens ?? 0); + return ( + + toggleToken(item.name)}> + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
{t("th.name")} handleBreakdownSort(key) : undefined} + > + {key ? ( + + {col.label} {renderBreakdownSortIcon(key)} + + ) : col.label} + {t("common.share")}{t("detail.primaryModels")}
+ + + + {tokenLabel} + + {formatNumber(item.calls)}{formatTokens(item.total_tokens)}{formatUSD(item.quota / 500000)}{userShare.toFixed(1)}%{getPrimaryModelNames(item.models) || t("common.noData")}
+
+ + + + + + + + + + + + {item.models.map((model) => { + const modelShare = getSharePercent(model.total_tokens, item.total_tokens); + return ( + + + + + + + + ); + })} + +
{t("detail.model")}{t("th.calls")}{t("th.totalToken")}{t("th.cost")}{t("common.share")}
{model.name}{formatNumber(model.calls)}{formatTokens(model.total_tokens)}{formatUSD(model.quota / 500000)}{modelShare.toFixed(1)}%
+
+
+ ); + } + return (
@@ -127,41 +252,71 @@ export default function DetailPage() { - {breakdownItems.length > 0 && ( + {(modelBreakdownItems.length > 0 || showTokenTab) && ( -

{breakdownLabel}

- - - - {breakdownColumns.map((col) => { - const key = col.key; - return ( - - ); - })} - - - - {sortedBreakdownItems.map((item) => ( - - - - - +
+

+ {activeBreakdownTab === "tokens" ? t("detail.tokenDist") : breakdownLabel} +

+ {showTokenTab && ( +
+ {([ + ["models", t("detail.modelDist")], + ["tokens", t("detail.tokenDist")], + ] as const).map(([value, label]) => ( + + ))} +
+ )} +
+ {activeBreakdownTab === "tokens" ? ( + renderTokenDistribution() + ) : sortedBreakdownItems.length > 0 ? ( +
handleBreakdownSort(key) : undefined} - > - {key ? ( - - {col.label} {renderBreakdownSortIcon(key)} - - ) : col.label} -
{item.name}{formatNumber(item.calls)}{formatTokens(item.total_tokens)}{formatUSD(item.quota / 500000)}
+ + + {breakdownColumns.map((col) => { + const key = col.key; + return ( + + ); + })} - ))} - -
handleBreakdownSort(key) : undefined} + > + {key ? ( + + {col.label} {renderBreakdownSortIcon(key)} + + ) : col.label} +
+ + + {sortedBreakdownItems.map((item) => ( + + {item.name} + {formatNumber(item.calls)} + {formatTokens(item.total_tokens)} + {formatUSD(item.quota / 500000)} + + ))} + + + ) : renderEmptyBreakdown()}
)} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 284425b..92527af 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -89,6 +89,11 @@ const translations = { "detail.trend": "使用趋势", "detail.modelDist": "模型分布", "detail.userDist": "用户分布", + "detail.tokenDist": "令牌分布", + "detail.primaryModels": "主要模型", + "detail.unnamedToken": "未命名令牌", + "detail.expandToken": "展开令牌", + "detail.collapseToken": "收起令牌", // theme "theme.light": "浅色", "theme.dark": "深色", @@ -210,6 +215,11 @@ const translations = { "detail.trend": "Usage Trend", "detail.modelDist": "Model Distribution", "detail.userDist": "User Distribution", + "detail.tokenDist": "Token Distribution", + "detail.primaryModels": "Primary Models", + "detail.unnamedToken": "Unnamed Token", + "detail.expandToken": "Expand token", + "detail.collapseToken": "Collapse token", "theme.light": "Light", "theme.dark": "Dark", "theme.system": "System",