22
33import { useEffect , useState , useCallback , useMemo , useRef , type FormEvent } from "react" ;
44import { ResponsiveContainer , LineChart , Line , CartesianGrid , XAxis , YAxis , Tooltip , BarChart , Bar , Legend , ComposedChart , PieChart , Pie , Cell } from "recharts" ;
5+ import type { TooltipProps } from "recharts" ;
56import { formatCurrency , formatNumber , formatCompactNumber , formatNumberWithCommas , formatHourLabel } from "@/lib/utils" ;
67import { AlertTriangle , Info , LucideIcon , Activity , Save , RefreshCw , Moon , Sun , Pencil , Trash2 , X , Maximize2 } from "lucide-react" ;
78import type { ModelPrice , UsageOverview , UsageSeriesPoint } from "@/lib/types" ;
@@ -29,6 +30,24 @@ const hourFormatter = new Intl.DateTimeFormat("en-CA", {
2930
3031const HOUR_MS = 60 * 60 * 1000 ;
3132
33+ type TooltipValue = number | string | Array < number | string > | undefined ;
34+
35+ function normalizeTooltipValue ( value : TooltipValue ) {
36+ if ( Array . isArray ( value ) ) return normalizeTooltipValue ( value [ 0 ] ) ;
37+ const numeric = typeof value === "number" ? value : Number ( value ?? 0 ) ;
38+ return Number . isFinite ( numeric ) ? numeric : 0 ;
39+ }
40+
41+ const trendTooltipFormatter : TooltipProps < number , string > [ "formatter" ] = ( value , name ) => {
42+ const numericValue = normalizeTooltipValue ( value ) ;
43+ return name === "费用" ? [ formatCurrency ( numericValue ) , name ] : [ formatNumberWithCommas ( numericValue ) , name ] ;
44+ } ;
45+
46+ const numericTooltipFormatter : TooltipProps < number , string > [ "formatter" ] = ( value , name ) => {
47+ const numericValue = normalizeTooltipValue ( value ) ;
48+ return [ formatNumberWithCommas ( numericValue ) , name ] ;
49+ } ;
50+
3251function formatHourKeyFromTs ( ts : number ) {
3352 const parts = hourFormatter . formatToParts ( new Date ( ts ) ) ;
3453 const month = parts . find ( ( p ) => p . type === "month" ) ?. value ?? "00" ;
@@ -135,13 +154,70 @@ export default function DashboardPage() {
135154 } ;
136155
137156 const handleHourlyLegendClick = ( e : any ) => {
138- const { dataKey } = e ;
157+ const key = e . dataKey ?? e . payload ?. dataKey ?? e . id ;
158+ if ( ! key ) return ;
139159 setHourlyVisible ( ( prev ) => ( {
140160 ...prev ,
141- [ dataKey ] : ! prev [ dataKey as string ] ,
161+ [ key ] : ! prev [ key as string ] ,
142162 } ) ) ;
143163 } ;
144164
165+ const TrendLegend : any = Legend ;
166+
167+ const trendConfig = useMemo ( ( ) => {
168+ const defs = {
169+ requests : { color : "#3b82f6" , formatter : ( v : any ) => formatCompactNumber ( v ) , name : "请求数" } ,
170+ tokens : { color : "#10b981" , formatter : ( v : any ) => formatCompactNumber ( v ) , name : "Tokens" } ,
171+ cost : { color : "#fbbf24" , formatter : ( v : any ) => formatCurrency ( v ) , name : "费用" } ,
172+ } ;
173+
174+ const visibleKeys = ( Object . keys ( trendVisible ) as Array < keyof typeof trendVisible > ) . filter ( ( k ) => trendVisible [ k ] ) ;
175+
176+ // Default mapping
177+ let lineAxisMap : Record < string , string > = {
178+ requests : "left" ,
179+ tokens : "right" ,
180+ cost : "cost" ,
181+ } ;
182+
183+ let leftAxisKey = "requests" ;
184+ let rightAxisKey = "tokens" ;
185+ let rightAxisVisible = true ;
186+
187+ if ( visibleKeys . length === 2 ) {
188+ if ( ! trendVisible . requests ) {
189+ // requests hidden -> tokens (left), cost (right)
190+ lineAxisMap = { requests : "left" , tokens : "left" , cost : "right" } ;
191+ leftAxisKey = "tokens" ;
192+ rightAxisKey = "cost" ;
193+ } else if ( ! trendVisible . tokens ) {
194+ // tokens hidden -> requests (left), cost (right)
195+ lineAxisMap = { requests : "left" , tokens : "right" , cost : "right" } ;
196+ leftAxisKey = "requests" ;
197+ rightAxisKey = "cost" ;
198+ } else {
199+ // cost hidden -> requests (left), tokens (right)
200+ lineAxisMap = { requests : "left" , tokens : "right" , cost : "cost" } ;
201+ leftAxisKey = "requests" ;
202+ rightAxisKey = "tokens" ;
203+ }
204+ } else if ( visibleKeys . length === 1 ) {
205+ const key = visibleKeys [ 0 ] ;
206+ lineAxisMap = { requests : "left" , tokens : "left" , cost : "left" } ;
207+ leftAxisKey = key ;
208+ rightAxisVisible = false ;
209+ } else if ( visibleKeys . length === 0 ) {
210+ rightAxisVisible = false ;
211+ }
212+
213+ return {
214+ lineAxisMap,
215+ leftAxis : defs [ leftAxisKey as keyof typeof defs ] ,
216+ rightAxis : defs [ rightAxisKey as keyof typeof defs ] ,
217+ rightAxisVisible
218+ } ;
219+ } , [ trendVisible ] ) ;
220+
145221 const cancelPieLegendClear = useCallback ( ( ) => {
146222 if ( pieLegendClearTimerRef . current !== null ) {
147223 window . clearTimeout ( pieLegendClearTimerRef . current ) ;
@@ -717,13 +793,19 @@ export default function DashboardPage() {
717793 < LineChart data = { overviewData . byDay } margin = { { top : 0 , right : 12 , left : 0 , bottom : 0 } } >
718794 < CartesianGrid stroke = "#334155" strokeDasharray = "5 5" />
719795 < XAxis dataKey = "label" stroke = "#94a3b8" fontSize = { 12 } />
720- < YAxis yAxisId = "left" stroke = "#3b82f6" tickFormatter = { ( v ) => formatCompactNumber ( v ) } fontSize = { 12 } />
796+ < YAxis
797+ yAxisId = "left"
798+ stroke = { trendConfig . leftAxis . color }
799+ tickFormatter = { trendConfig . leftAxis . formatter }
800+ fontSize = { 12 }
801+ />
721802 < YAxis
722803 yAxisId = "right"
723804 orientation = "right"
724- stroke = "#10b981"
725- tickFormatter = { ( v ) => formatCompactNumber ( v ) }
805+ stroke = { trendConfig . rightAxis . color }
806+ tickFormatter = { trendConfig . rightAxis . formatter }
726807 fontSize = { 12 }
808+ hide = { ! trendConfig . rightAxisVisible }
727809 />
728810 < YAxis
729811 yAxisId = "cost"
@@ -732,20 +814,22 @@ export default function DashboardPage() {
732814 tickFormatter = { ( v ) => formatCurrency ( v ) }
733815 fontSize = { 12 }
734816 hide
817+ width = { 0 }
735818 />
736819 < Tooltip
737820 contentStyle = { { borderRadius : 12 , backgroundColor : "rgba(0,0,0,0.8)" , border : "1px solid rgba(100,116,139,0.6)" , color : "#f8fafc" } }
738- formatter = { ( value : number , name : string ) => name === "费用" ? [ formatCurrency ( value ) , name ] : [ formatNumberWithCommas ( value ) , name ] }
821+ formatter = { trendTooltipFormatter }
739822 />
740- < Legend
823+ < TrendLegend
741824 height = { 24 }
742825 iconSize = { 10 }
743826 wrapperStyle = { { paddingTop : 0 , paddingBottom : 0 , lineHeight : "24px" , cursor : "pointer" } }
744827 onClick = { handleTrendLegendClick }
828+ itemSorter = { ( item : any ) => ( { requests : 0 , tokens : 1 , cost : 2 } as Record < string , number > ) [ item ?. dataKey ] ?? 999 }
745829 />
746- < Line hide = { ! trendVisible . requests } yAxisId = "left" type = "monotone" dataKey = "requests" stroke = "#3b82f6" strokeWidth = { 2 } name = "请求数" dot = { { r : 3 } } />
747- < Line hide = { ! trendVisible . tokens } yAxisId = "right" type = "monotone" dataKey = "tokens" stroke = "#10b981" strokeWidth = { 2 } name = "Tokens" dot = { { r : 3 } } />
748- < Line hide = { ! trendVisible . cost } yAxisId = " cost" type = "monotone" dataKey = "cost" stroke = "#fbbf24" strokeWidth = { 2 } name = "费用" dot = { { r : 3 } } />
830+ < Line hide = { ! trendVisible . requests } yAxisId = { trendConfig . lineAxisMap . requests } type = "monotone" dataKey = "requests" stroke = "#3b82f6" strokeWidth = { 2 } name = "请求数" dot = { { r : 3 } } />
831+ < Line hide = { ! trendVisible . tokens } yAxisId = { trendConfig . lineAxisMap . tokens } type = "monotone" dataKey = "tokens" stroke = "#10b981" strokeWidth = { 2 } name = "Tokens" dot = { { r : 3 } } />
832+ < Line hide = { ! trendVisible . cost } yAxisId = { trendConfig . lineAxisMap . cost } type = "monotone" dataKey = "cost" stroke = "#fbbf24" strokeWidth = { 2 } name = "费用" dot = { { r : 3 } } />
749833 </ LineChart >
750834 </ ResponsiveContainer >
751835 ) }
@@ -962,18 +1046,26 @@ export default function DashboardPage() {
9621046 < YAxis yAxisId = "right" orientation = "right" stroke = { darkMode ? "#94a3b8" : "#64748b" } tickFormatter = { ( v ) => formatCompactNumber ( v ) } fontSize = { 12 } />
9631047 < Tooltip
9641048 contentStyle = { { borderRadius : 12 , backgroundColor : "rgba(0,0,0,0.8)" , border : "1px solid rgba(100,116,139,0.6)" , color : "#f8fafc" } }
965- formatter = { ( value : number , name : string ) => [ formatNumberWithCommas ( value ) , name ] }
1049+ formatter = { numericTooltipFormatter }
9661050 labelFormatter = { ( label ) => formatHourLabel ( label ) }
9671051 />
968- < Legend
1052+ < TrendLegend
9691053 wrapperStyle = { { cursor : "pointer" } }
9701054 onClick = { handleHourlyLegendClick }
1055+ itemSorter = { ( item : any ) => ( { requests : 0 , inputTokens : 1 , outputTokens : 2 , reasoningTokens : 3 , cachedTokens : 4 } as Record < string , number > ) [ item ?. dataKey ] ?? 999 }
1056+ payload = { [
1057+ { value : "请求数" , type : "line" , id : "requests" , color : "#3b82f6" , dataKey : "requests" } ,
1058+ { value : "输入" , type : "square" , id : "inputTokens" , color : "#60a5fa" , dataKey : "inputTokens" } ,
1059+ { value : "输出" , type : "square" , id : "outputTokens" , color : "#4ade80" , dataKey : "outputTokens" } ,
1060+ { value : "思考" , type : "square" , id : "reasoningTokens" , color : "#fbbf24" , dataKey : "reasoningTokens" } ,
1061+ { value : "缓存" , type : "square" , id : "cachedTokens" , color : "#c084fc" , dataKey : "cachedTokens" } ,
1062+ ] }
9711063 />
972- { /* 堆积柱状图 - 柔和配色,仅顶部圆角 */ }
973- < Bar hide = { ! hourlyVisible . inputTokens } yAxisId = "right" dataKey = "inputTokens" name = "输入" stackId = "tokens" fill = "#60a5fa" />
974- < Bar hide = { ! hourlyVisible . outputTokens } yAxisId = "right" dataKey = "outputTokens" name = "输出" stackId = "tokens" fill = "#4ade80" />
975- < Bar hide = { ! hourlyVisible . reasoningTokens } yAxisId = "right" dataKey = "reasoningTokens" name = "思考" stackId = "tokens" fill = "#fbbf24" />
976- < Bar hide = { ! hourlyVisible . cachedTokens } yAxisId = "right" dataKey = "cachedTokens" name = "缓存" stackId = "tokens" fill = "#c084fc" radius = { [ 4 , 4 , 0 , 0 ] } />
1064+ { /* 堆积柱状图 - 柔和配色,仅顶部圆角,增强动画 */ }
1065+ < Bar hide = { ! hourlyVisible . inputTokens } yAxisId = "right" dataKey = "inputTokens" name = "输入" stackId = "tokens" fill = "#60a5fa" animationDuration = { 600 } />
1066+ < Bar hide = { ! hourlyVisible . outputTokens } yAxisId = "right" dataKey = "outputTokens" name = "输出" stackId = "tokens" fill = "#4ade80" animationDuration = { 600 } />
1067+ < Bar hide = { ! hourlyVisible . reasoningTokens } yAxisId = "right" dataKey = "reasoningTokens" name = "思考" stackId = "tokens" fill = "#fbbf24" animationDuration = { 600 } />
1068+ < Bar hide = { ! hourlyVisible . cachedTokens } yAxisId = "right" dataKey = "cachedTokens" name = "缓存" stackId = "tokens" fill = "#c084fc" radius = { [ 4 , 4 , 0 , 0 ] } animationDuration = { 600 } />
9771069 { /* 曲线在最上层 - 带描边突出显示 */ }
9781070 < Line
9791071 hide = { ! hourlyVisible . requests }
@@ -1276,22 +1368,42 @@ export default function DashboardPage() {
12761368 < LineChart data = { overviewData . byDay } margin = { { top : 0 , right : 40 , left : 0 , bottom : 0 } } >
12771369 < CartesianGrid stroke = "#334155" strokeDasharray = "5 5" />
12781370 < XAxis dataKey = "label" stroke = "#94a3b8" fontSize = { 12 } />
1279- < YAxis yAxisId = "left" stroke = "#3b82f6" tickFormatter = { ( v ) => formatCompactNumber ( v ) } fontSize = { 12 } />
1280- < YAxis yAxisId = "right" orientation = "right" stroke = "#10b981" tickFormatter = { ( v ) => formatCompactNumber ( v ) } fontSize = { 12 } />
1281- < YAxis yAxisId = "cost" orientation = "right" stroke = "#fbbf24" tickFormatter = { ( v ) => formatCurrency ( v ) } fontSize = { 12 } />
1371+ < YAxis
1372+ yAxisId = "left"
1373+ stroke = { trendConfig . leftAxis . color }
1374+ tickFormatter = { trendConfig . leftAxis . formatter }
1375+ fontSize = { 12 }
1376+ />
1377+ < YAxis
1378+ yAxisId = "right"
1379+ orientation = "right"
1380+ stroke = { trendConfig . rightAxis . color }
1381+ tickFormatter = { trendConfig . rightAxis . formatter }
1382+ fontSize = { 12 }
1383+ hide = { ! trendConfig . rightAxisVisible }
1384+ />
1385+ < YAxis
1386+ yAxisId = "cost"
1387+ orientation = "right"
1388+ stroke = "#fbbf24"
1389+ tickFormatter = { ( v ) => formatCurrency ( v ) }
1390+ fontSize = { 12 }
1391+ hide = { trendConfig . lineAxisMap . cost !== 'cost' }
1392+ />
12821393 < Tooltip
12831394 contentStyle = { { borderRadius : 12 , backgroundColor : "rgba(0,0,0,0.8)" , border : "1px solid rgba(100,116,139,0.6)" , color : "#f8fafc" } }
1284- formatter = { ( value : number , name : string ) => name === "费用" ? [ formatCurrency ( value ) , name ] : [ formatNumberWithCommas ( value ) , name ] }
1395+ formatter = { trendTooltipFormatter }
12851396 />
1286- < Legend
1397+ < TrendLegend
12871398 height = { 24 }
12881399 iconSize = { 10 }
12891400 wrapperStyle = { { paddingTop : 0 , paddingBottom : 0 , lineHeight : "24px" , cursor : "pointer" } }
12901401 onClick = { handleTrendLegendClick }
1402+ itemSorter = { ( item : any ) => ( { requests : 0 , tokens : 1 , cost : 2 } as Record < string , number > ) [ item ?. dataKey ] ?? 999 }
12911403 />
1292- < Line hide = { ! trendVisible . requests } yAxisId = "left" type = "monotone" dataKey = "requests" stroke = "#3b82f6" strokeWidth = { 2 } name = "请求数" dot = { { r : 3 } } />
1293- < Line hide = { ! trendVisible . tokens } yAxisId = "right" type = "monotone" dataKey = "tokens" stroke = "#10b981" strokeWidth = { 2 } name = "Tokens" dot = { { r : 3 } } />
1294- < Line hide = { ! trendVisible . cost } yAxisId = " cost" type = "monotone" dataKey = "cost" stroke = "#fbbf24" strokeWidth = { 2 } name = "费用" dot = { { r : 3 } } />
1404+ < Line hide = { ! trendVisible . requests } yAxisId = { trendConfig . lineAxisMap . requests } type = "monotone" dataKey = "requests" stroke = "#3b82f6" strokeWidth = { 2 } name = "请求数" dot = { { r : 3 } } />
1405+ < Line hide = { ! trendVisible . tokens } yAxisId = { trendConfig . lineAxisMap . tokens } type = "monotone" dataKey = "tokens" stroke = "#10b981" strokeWidth = { 2 } name = "Tokens" dot = { { r : 3 } } />
1406+ < Line hide = { ! trendVisible . cost } yAxisId = { trendConfig . lineAxisMap . cost } type = "monotone" dataKey = "cost" stroke = "#fbbf24" strokeWidth = { 2 } name = "费用" dot = { { r : 3 } } />
12951407 </ LineChart >
12961408 </ ResponsiveContainer >
12971409 ) }
@@ -1419,17 +1531,27 @@ export default function DashboardPage() {
14191531 < YAxis yAxisId = "right" orientation = "right" stroke = { darkMode ? "#94a3b8" : "#64748b" } tickFormatter = { ( v ) => formatCompactNumber ( v ) } fontSize = { 12 } />
14201532 < Tooltip
14211533 contentStyle = { { borderRadius : 12 , backgroundColor : "rgba(0,0,0,0.8)" , border : "1px solid rgba(100,116,139,0.6)" , color : "#f8fafc" } }
1422- formatter = { ( value : number , name : string ) => [ formatNumberWithCommas ( value ) , name ] }
1534+ formatter = { numericTooltipFormatter }
14231535 labelFormatter = { ( label ) => formatHourLabel ( label ) }
14241536 />
1425- < Legend
1537+ < TrendLegend
14261538 wrapperStyle = { { cursor : "pointer" } }
14271539 onClick = { handleHourlyLegendClick }
1540+ itemSorter = { ( item : any ) => ( { requests : 0 , inputTokens : 1 , outputTokens : 2 , reasoningTokens : 3 , cachedTokens : 4 } as Record < string , number > ) [ item ?. dataKey ] ?? 999 }
1541+ payload = { [
1542+ { value : "请求数" , type : "line" , id : "requests" , color : "#3b82f6" , dataKey : "requests" } ,
1543+ { value : "输入" , type : "square" , id : "inputTokens" , color : "#60a5fa" , dataKey : "inputTokens" } ,
1544+ { value : "输出" , type : "square" , id : "outputTokens" , color : "#4ade80" , dataKey : "outputTokens" } ,
1545+ { value : "思考" , type : "square" , id : "reasoningTokens" , color : "#fbbf24" , dataKey : "reasoningTokens" } ,
1546+ { value : "缓存" , type : "square" , id : "cachedTokens" , color : "#c084fc" , dataKey : "cachedTokens" } ,
1547+ ] }
14281548 />
1429- < Bar hide = { ! hourlyVisible . inputTokens } yAxisId = "right" dataKey = "inputTokens" name = "输入" stackId = "tokens" fill = "#60a5fa" />
1430- < Bar hide = { ! hourlyVisible . outputTokens } yAxisId = "right" dataKey = "outputTokens" name = "输出" stackId = "tokens" fill = "#4ade80" />
1431- < Bar hide = { ! hourlyVisible . reasoningTokens } yAxisId = "right" dataKey = "reasoningTokens" name = "思考" stackId = "tokens" fill = "#fbbf24" />
1432- < Bar hide = { ! hourlyVisible . cachedTokens } yAxisId = "right" dataKey = "cachedTokens" name = "缓存" stackId = "tokens" fill = "#c084fc" radius = { [ 4 , 4 , 0 , 0 ] } />
1549+ { /* 堆积柱状图 - 柔和配色,仅顶部圆角,增强动画 */ }
1550+ < Bar hide = { ! hourlyVisible . inputTokens } yAxisId = "right" dataKey = "inputTokens" name = "输入" stackId = "tokens" fill = "#60a5fa" animationDuration = { 600 } />
1551+ < Bar hide = { ! hourlyVisible . outputTokens } yAxisId = "right" dataKey = "outputTokens" name = "输出" stackId = "tokens" fill = "#4ade80" animationDuration = { 600 } />
1552+ < Bar hide = { ! hourlyVisible . reasoningTokens } yAxisId = "right" dataKey = "reasoningTokens" name = "思考" stackId = "tokens" fill = "#fbbf24" animationDuration = { 600 } />
1553+ < Bar hide = { ! hourlyVisible . cachedTokens } yAxisId = "right" dataKey = "cachedTokens" name = "缓存" stackId = "tokens" fill = "#c084fc" animationDuration = { 600 } />
1554+ { /* 曲线在最上层 - 带描边突出显示 */ }
14331555 < Line
14341556 hide = { ! hourlyVisible . requests }
14351557 yAxisId = "left"
0 commit comments