返回 oh-my-ppt
TokenUsageDashboard.tsx
根目录 / src / renderer / src / components / token-usage / TokenUsageDashboard.tsx
1 import { useEffect, useMemo, useRef, useState } from 'react'
2 import Chart from 'chart.js/auto'
3 import {
4 Activity,
5 ArrowDownToLine,
6 ArrowUpFromLine,
7 Coins,
8 RefreshCw
9 } from 'lucide-react'
10 import type { ModelUsagePeriod, ModelUsageStats } from '@shared/model-usage'
11 import { ipc } from '../../lib/ipc'
12 import { useLang } from '../../i18n'
13 import { Button } from '../ui/Button'
14 import { Tabs, TabsList, TabsTrigger } from '../ui/Tabs'
15
16 const CHART_COLORS = ['#5D6B4D', '#8FBC8F', '#C8B89E', '#D4E4C1', '#3E4A32', '#a8b89a']
17
18 const formatTokens = (value: number): string => {
19 if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
20 if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
21 return value.toLocaleString()
22 }
23
24 export function TokenUsageDashboard(): React.JSX.Element {
25 const { t } = useLang()
26 const [period, setPeriod] = useState<ModelUsagePeriod>('30d')
27 const [stats, setStats] = useState<ModelUsageStats | null>(null)
28 const [loading, setLoading] = useState(true)
29 const [error, setError] = useState('')
30 const [refreshKey, setRefreshKey] = useState(0)
31 const trendCanvasRef = useRef<HTMLCanvasElement | null>(null)
32 const modelCanvasRef = useRef<HTMLCanvasElement | null>(null)
33 const hourlyCanvasRef = useRef<HTMLCanvasElement | null>(null)
34 const [todayStats, setTodayStats] = useState<ModelUsageStats | null>(null)
35
36 useEffect(() => {
37 let active = true
38 setLoading(true)
39 setError('')
40 void ipc
41 .getModelUsage(period)
42 .then((result) => {
43 if (active) setStats(result)
44 })
45 .catch((loadError: unknown) => {
46 if (!active) return
47 setError(loadError instanceof Error ? loadError.message : t('settings.usageLoadFailed'))
48 })
49 .finally(() => {
50 if (active) setLoading(false)
51 })
52 return () => {
53 active = false
54 }
55 }, [period, refreshKey, t])
56
57 useEffect(() => {
58 let active = true
59 void ipc
60 .getModelUsage('today')
61 .then((result) => {
62 if (active) setTodayStats(result)
63 })
64 .catch(() => {
65 /* hourly panel is supplementary, ignore errors */
66 })
67 return () => {
68 active = false
69 }
70 }, [refreshKey])
71
72 const modelRows = useMemo(() => stats?.byModel.slice(0, 8) || [], [stats])
73 const modelMax = useMemo(
74 () => modelRows.reduce((max, item) => Math.max(max, item.totalTokens), 0) || 1,
75 [modelRows]
76 )
77
78 const totalTokens = stats?.totals.totalTokens || 0
79 const inputTokens = stats?.totals.inputTokens || 0
80 const outputTokens = stats?.totals.outputTokens || 0
81 const callCount = stats?.totals.callCount || 0
82
83 const statCards = [
84 {
85 label: t('settings.usageTotalTokens'),
86 value: totalTokens,
87 icon: Coins,
88 bg: 'bg-[#d4e4c1]',
89 border: 'border-[#c8d6ba]',
90 iconBg: 'bg-[#5d6b4d]',
91 blob: 'bg-[#8fbc8f]/30'
92 },
93 {
94 label: t('settings.usageInputTokens'),
95 value: inputTokens,
96 icon: ArrowDownToLine,
97 bg: 'bg-[#e8e0d0]',
98 border: 'border-[#d9cfbd]',
99 iconBg: 'bg-[#6f8a5b]',
100 blob: 'bg-[#d4e4c1]/55'
101 },
102 {
103 label: t('settings.usageOutputTokens'),
104 value: outputTokens,
105 icon: ArrowUpFromLine,
106 bg: 'bg-[#f5f1e8]',
107 border: 'border-[#e0d8c8]',
108 iconBg: 'bg-[#b18f5e]',
109 blob: 'bg-[#c8b89e]/30'
110 },
111 {
112 label: t('settings.usageCalls'),
113 value: callCount,
114 icon: Activity,
115 bg: 'bg-[#d4e4c1]',
116 border: 'border-[#a9bd97]',
117 iconBg: 'bg-[#3e4a32]',
118 blob: 'bg-[#5d6b4d]/22'
119 }
120 ]
121
122 useEffect(() => {
123 const canvas = trendCanvasRef.current
124 if (!canvas || !stats) return
125 const totals = stats.byDay.map((item) => item.totalTokens)
126 const chart = new Chart(canvas, {
127 data: {
128 labels: stats.byDay.map((item) => item.date.slice(5)),
129 datasets: [
130 {
131 type: 'bar',
132 label: t('settings.usageInputTokens'),
133 data: stats.byDay.map((item) => item.inputTokens),
134 backgroundColor: '#9bb48b',
135 borderRadius: { topLeft: 5, topRight: 5, bottomLeft: 0, bottomRight: 0 },
136 borderSkipped: false,
137 stack: 'tokens',
138 maxBarThickness: 26,
139 yAxisID: 'y',
140 order: 2
141 },
142 {
143 type: 'bar',
144 label: t('settings.usageOutputTokens'),
145 data: stats.byDay.map((item) => item.outputTokens),
146 backgroundColor: '#d9a26f',
147 borderRadius: { topLeft: 5, topRight: 5, bottomLeft: 0, bottomRight: 0 },
148 borderSkipped: false,
149 stack: 'tokens',
150 maxBarThickness: 26,
151 yAxisID: 'y',
152 order: 3
153 },
154 {
155 type: 'line',
156 label: t('settings.usageTotalTokens'),
157 data: totals,
158 borderColor: '#3E4A32',
159 backgroundColor: '#3E4A32',
160 borderWidth: 2,
161 tension: 0.4,
162 pointRadius: 0,
163 pointHoverRadius: 5,
164 pointHoverBackgroundColor: '#3E4A32',
165 pointHoverBorderColor: '#F5F1E8',
166 pointHoverBorderWidth: 2,
167 yAxisID: 'y1',
168 order: 1
169 }
170 ]
171 },
172 options: {
173 responsive: true,
174 maintainAspectRatio: false,
175 interaction: { intersect: false, mode: 'index' },
176 plugins: {
177 legend: {
178 position: 'bottom',
179 labels: {
180 boxWidth: 8,
181 boxHeight: 8,
182 padding: 10,
183 color: '#5D6B4D',
184 font: { size: 10 }
185 }
186 },
187 tooltip: {
188 backgroundColor: '#3E4A32',
189 padding: 10,
190 cornerRadius: 10,
191 titleColor: '#F5F1E8',
192 bodyColor: '#D4E4C1',
193 titleFont: { size: 12 },
194 bodyFont: { size: 12 },
195 displayColors: true,
196 boxPadding: 4,
197 callbacks: {
198 label: (context) => `${context.dataset.label}: ${Number(context.raw).toLocaleString()}`
199 }
200 }
201 },
202 scales: {
203 x: {
204 stacked: true,
205 grid: { display: false },
206 ticks: { color: '#8A9A7B', font: { size: 11 } },
207 border: { display: false }
208 },
209 y: {
210 stacked: true,
211 beginAtZero: true,
212 ticks: {
213 callback: (value) => formatTokens(Number(value)),
214 color: '#8A9A7B',
215 font: { size: 11 }
216 },
217 grid: { color: 'rgba(93, 107, 77, 0.08)' },
218 border: { display: false }
219 },
220 y1: {
221 stacked: false,
222 beginAtZero: true,
223 display: false,
224 max: Math.max(...totals, 1) * 1.15
225 }
226 }
227 }
228 })
229 return () => chart.destroy()
230 }, [stats, t])
231
232 useEffect(() => {
233 const canvas = modelCanvasRef.current
234 if (!canvas || modelRows.length === 0) return
235 const chart = new Chart(canvas, {
236 type: 'doughnut',
237 data: {
238 labels: modelRows.map((item) => item.model),
239 datasets: [
240 {
241 data: modelRows.map((item) => item.totalTokens),
242 backgroundColor: modelRows.map((_, index) => CHART_COLORS[index % CHART_COLORS.length]),
243 borderColor: '#F5F1E8',
244 borderWidth: 3,
245 hoverOffset: 8
246 }
247 ]
248 },
249 options: {
250 responsive: true,
251 maintainAspectRatio: false,
252 cutout: '68%',
253 plugins: {
254 legend: { display: false },
255 tooltip: {
256 backgroundColor: '#3E4A32',
257 padding: 10,
258 cornerRadius: 10,
259 titleColor: '#F5F1E8',
260 bodyColor: '#D4E4C1',
261 callbacks: {
262 label: (context) => `${context.label}: ${formatTokens(Number(context.raw))}`
263 }
264 }
265 }
266 }
267 })
268 return () => chart.destroy()
269 }, [modelRows])
270
271 useEffect(() => {
272 const canvas = hourlyCanvasRef.current
273 const hours = todayStats?.byHour
274 if (!canvas || !Array.isArray(hours) || hours.length === 0) return
275 const ctx = canvas.getContext('2d')
276 const makeFill = (color: string) => {
277 if (!ctx) return `${color}22`
278 const gradient = ctx.createLinearGradient(0, 0, 0, 200)
279 gradient.addColorStop(0, `${color}40`)
280 gradient.addColorStop(1, `${color}00`)
281 return gradient
282 }
283 const chart = new Chart(canvas, {
284 type: 'line',
285 data: {
286 labels: hours.map((item) => `${String(item.hour).padStart(2, '0')}:00`),
287 datasets: [
288 {
289 label: t('settings.usageInputTokens'),
290 data: hours.map((item) => item.inputTokens),
291 borderColor: '#5D6B4D',
292 backgroundColor: makeFill('#5D6B4D'),
293 borderWidth: 2,
294 tension: 0.4,
295 fill: true,
296 pointRadius: 0,
297 pointHoverRadius: 4,
298 pointHoverBackgroundColor: '#5D6B4D',
299 pointHoverBorderColor: '#F5F1E8',
300 pointHoverBorderWidth: 2
301 },
302 {
303 label: t('settings.usageOutputTokens'),
304 data: hours.map((item) => item.outputTokens),
305 borderColor: '#8FBC8F',
306 backgroundColor: makeFill('#8FBC8F'),
307 borderWidth: 2,
308 tension: 0.4,
309 fill: true,
310 pointRadius: 0,
311 pointHoverRadius: 4,
312 pointHoverBackgroundColor: '#8FBC8F',
313 pointHoverBorderColor: '#F5F1E8',
314 pointHoverBorderWidth: 2
315 }
316 ]
317 },
318 options: {
319 responsive: true,
320 maintainAspectRatio: false,
321 interaction: { intersect: false, mode: 'index' },
322 plugins: {
323 legend: {
324 position: 'bottom',
325 labels: {
326 boxWidth: 8,
327 boxHeight: 8,
328 padding: 10,
329 color: '#5D6B4D',
330 font: { size: 10 }
331 }
332 },
333 tooltip: {
334 backgroundColor: '#3E4A32',
335 padding: 10,
336 cornerRadius: 10,
337 titleColor: '#F5F1E8',
338 bodyColor: '#D4E4C1',
339 titleFont: { size: 12 },
340 bodyFont: { size: 12 },
341 displayColors: true,
342 boxPadding: 4,
343 callbacks: {
344 label: (context) => `${context.dataset.label}: ${Number(context.raw).toLocaleString()}`
345 }
346 }
347 },
348 scales: {
349 x: {
350 grid: { display: false },
351 ticks: {
352 color: '#8A9A7B',
353 font: { size: 10 },
354 maxRotation: 0,
355 autoSkip: true,
356 maxTicksLimit: 8
357 },
358 border: { display: false }
359 },
360 y: {
361 beginAtZero: true,
362 ticks: {
363 callback: (value) => formatTokens(Number(value)),
364 color: '#8A9A7B',
365 font: { size: 10 }
366 },
367 grid: { color: 'rgba(93, 107, 77, 0.08)' },
368 border: { display: false }
369 }
370 }
371 }
372 })
373 return () => chart.destroy()
374 }, [todayStats, t])
375
376 const periods: Array<{ value: ModelUsagePeriod; label: string }> = [
377 { value: '7d', label: t('settings.usagePeriod7d') },
378 { value: '30d', label: t('settings.usagePeriod30d') },
379 { value: 'all', label: t('settings.usagePeriodAll') }
380 ]
381
382 if (loading && !stats) {
383 return (
384 <div className="flex items-center justify-center gap-3 py-16 text-[#5D6B4D]">
385 <RefreshCw className="h-4 w-4 animate-spin" />
386 <span className="text-sm">{t('settings.usageLoading')}</span>
387 </div>
388 )
389 }
390
391 return (
392 <div className="space-y-4">
393 <div className="flex flex-wrap items-center justify-between gap-2">
394 <Tabs
395 value={period}
396 onValueChange={(value) => setPeriod(value as ModelUsagePeriod)}
397 >
398 <TabsList className="min-h-9 rounded-full bg-[#F5F1E8] px-1">
399 {periods.map((item) => (
400 <TabsTrigger
401 key={item.value}
402 value={item.value}
403 className="h-7 rounded-full px-3.5 text-xs data-[state=active]:bg-[#5D6B4D] data-[state=active]:text-[#F5F1E8] data-[state=active]:shadow-none"
404 >
405 {item.label}
406 </TabsTrigger>
407 ))}
408 </TabsList>
409 </Tabs>
410 <Button
411 variant="ghost"
412 size="sm"
413 disabled={loading}
414 onClick={() => setRefreshKey((value) => value + 1)}
415 className="h-8 text-[#5D6B4D] hover:text-[#3E4A32]"
416 >
417 <RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
418 {t('settings.usageRefresh')}
419 </Button>
420 </div>
421
422 {error ? (
423 <div className="rounded-2xl border border-[#C8B89E] bg-[#E8E0D0] p-4 text-sm text-[#3E4A32]">
424 {error}
425 </div>
426 ) : null}
427
428 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
429 {statCards.map((card) => {
430 const Icon = card.icon
431 return (
432 <div
433 key={card.label}
434 className={`group relative overflow-hidden rounded-[1.5rem] border ${card.border} ${card.bg} p-5 shadow-[0_14px_34px_rgba(86,73,54,0.10)] transition-transform duration-200 hover:-translate-y-0.5`}
435 >
436 <div
437 className={`pointer-events-none absolute -right-8 -top-8 h-24 w-24 rounded-[30%_70%_70%_30%/30%_30%_70%_70%] ${card.blob} transition-transform duration-300 group-hover:scale-110`}
438 />
439 <div className="relative flex items-start justify-between">
440 <div
441 className={`flex h-10 w-10 items-center justify-center rounded-[10%_90%_16%_84%/78%_22%_78%_22%] ${card.iconBg} text-white shadow-[0_8px_18px_rgba(86,73,54,0.16)]`}
442 >
443 <Icon className="h-4 w-4" />
444 </div>
445 </div>
446 <div className="relative mt-4">
447 <p className="text-[10px] font-medium uppercase tracking-[0.16em] text-[#5d6b4d]">
448 {card.label}
449 </p>
450 <p className="organic-serif mt-1.5 text-[24px] font-semibold leading-none text-[#3e4a32]">
451 {card.value.toLocaleString()}
452 </p>
453 </div>
454 </div>
455 )
456 })}
457 </div>
458
459 <div className="rounded-2xl bg-white p-5">
460 <h3 className="organic-serif mb-3 text-base font-semibold text-[#3E4A32]">
461 {t('settings.usageTodayHourly')}
462 </h3>
463 <div className="h-[clamp(200px,20vw,320px)]">
464 {todayStats?.byHour?.some((item) => item.callCount > 0) ? (
465 <canvas ref={hourlyCanvasRef} />
466 ) : (
467 <div className="flex h-full items-center justify-center text-sm text-[#8A9A7B]">
468 {t('settings.usageEmpty')}
469 </div>
470 )}
471 </div>
472 </div>
473
474 <div className="grid gap-4 lg:grid-cols-[minmax(0,1.65fr)_minmax(280px,1fr)]">
475 <div className="flex h-full flex-col rounded-2xl bg-white p-5">
476 <h3 className="organic-serif mb-3 text-base font-semibold text-[#3E4A32]">
477 {t('settings.usageTrend')}
478 </h3>
479 <div className="min-h-[240px] flex-1">
480 {stats?.byDay.length ? (
481 <canvas ref={trendCanvasRef} />
482 ) : (
483 <div className="flex h-full items-center justify-center text-sm text-[#8A9A7B]">
484 {t('settings.usageEmpty')}
485 </div>
486 )}
487 </div>
488 </div>
489
490 <div className="rounded-2xl bg-white p-5">
491 <h3 className="organic-serif mb-3 text-base font-semibold text-[#3E4A32]">
492 {t('settings.usageByModel')}
493 </h3>
494 {modelRows.length ? (
495 <>
496 <div className="mx-auto h-[140px] max-w-[180px]">
497 <canvas ref={modelCanvasRef} />
498 </div>
499 <div className="mt-3 space-y-2">
500 {modelRows.map((item, index) => {
501 const color = CHART_COLORS[index % CHART_COLORS.length]
502 const barPct = (item.totalTokens / modelMax) * 100
503 return (
504 <div key={`${item.provider}:${item.model}`}>
505 <div className="flex items-center gap-2 text-xs">
506 <span
507 className="h-2 w-2 shrink-0 rounded-full"
508 style={{ backgroundColor: color }}
509 />
510 <span
511 className="min-w-0 flex-1 truncate text-[#3E4A32]"
512 title={`${item.provider} / ${item.model}`}
513 >
514 {item.model}
515 </span>
516 <span className="tabular-nums text-[#5D6B4D]">
517 {formatTokens(item.totalTokens)}
518 </span>
519 </div>
520 <div className="mt-1 h-1 overflow-hidden rounded-full bg-[#F5F1E8]">
521 <div
522 className="h-full rounded-full transition-all"
523 style={{ width: `${barPct}%`, backgroundColor: color }}
524 />
525 </div>
526 </div>
527 )
528 })}
529 </div>
530 </>
531 ) : (
532 <div className="flex h-[220px] items-center justify-center text-sm text-[#8A9A7B]">
533 {t('settings.usageEmpty')}
534 </div>
535 )}
536 </div>
537 </div>
538 </div>
539 )
540 }
541
541 lines Plain Text