mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-02 19:00:49 +08:00
feat: enhance MainLayout with brand name expansion feature, sidebar toggle improvements, and responsive design adjustments for better user experience
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, SVGProps, useEffect, useMemo, useState } from 'react';
|
||||
import { ReactNode, SVGProps, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -142,9 +142,40 @@ export function MainLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
|
||||
|
||||
const fullBrandName = 'CLI Proxy API Management Center';
|
||||
const abbrBrandName = t('title.abbr');
|
||||
|
||||
// 5秒后自动收起品牌名称
|
||||
useEffect(() => {
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
setBrandExpanded(false);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (brandCollapseTimer.current) {
|
||||
clearTimeout(brandCollapseTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
setBrandExpanded(true);
|
||||
// 点击展开后,5秒后再次收起
|
||||
if (brandCollapseTimer.current) {
|
||||
clearTimeout(brandCollapseTimer.current);
|
||||
}
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
setBrandExpanded(false);
|
||||
}, 5000);
|
||||
}
|
||||
}, [brandExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
// ignore initial failure; login flow会提示
|
||||
@@ -213,35 +244,24 @@ export function MainLayout() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="brand">{sidebarCollapsed ? t('title.abbr').charAt(0) : t('title.abbr')}</div>
|
||||
<div className="nav-section">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<header className="main-header">
|
||||
<div className="left">
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
className="sidebar-toggle-header"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
title={sidebarCollapsed ? t('sidebar.expand', { defaultValue: '展开' }) : t('sidebar.collapse', { defaultValue: '收起' })}
|
||||
>
|
||||
{sidebarCollapsed ? '»' : '«'}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div className="content">
|
||||
<header className="main-header">
|
||||
<div className="left">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}>
|
||||
<div
|
||||
className={`brand-header ${brandExpanded ? 'expanded' : 'collapsed'}`}
|
||||
onClick={handleBrandClick}
|
||||
title={brandExpanded ? undefined : fullBrandName}
|
||||
>
|
||||
<span className="brand-full">{fullBrandName}</span>
|
||||
<span className="brand-abbr">{abbrBrandName}</span>
|
||||
</div>
|
||||
<Button className="mobile-menu-btn" variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}>
|
||||
☰
|
||||
</Button>
|
||||
<div className="connection">
|
||||
@@ -258,7 +278,7 @@ export function MainLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div className="header-actions">
|
||||
<Button variant="ghost" size="sm" onClick={handleRefreshAll} title={t('header.refresh_all')}>
|
||||
↻
|
||||
</Button>
|
||||
@@ -274,6 +294,25 @@ export function MainLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="main-body">
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
<div className="nav-section">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="content">
|
||||
<main className="main-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -289,5 +328,6 @@ export function MainLayout() {
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -441,8 +441,12 @@
|
||||
"total_tokens": "Total Tokens",
|
||||
"cached_tokens": "Cached Tokens",
|
||||
"reasoning_tokens": "Reasoning Tokens",
|
||||
"rpm_30m": "RPM (last 30 min)",
|
||||
"tpm_30m": "TPM (last 30 min)",
|
||||
"rpm_30m": "RPM",
|
||||
"tpm_30m": "TPM",
|
||||
"rate_30m": "Rate (last 30 min)",
|
||||
"model_name": "Model Name",
|
||||
"model_price_settings": "Model Pricing Settings",
|
||||
"saved_prices": "Saved Prices",
|
||||
"requests_trend": "Request Trends",
|
||||
"tokens_trend": "Token Usage Trends",
|
||||
"api_details": "API Details",
|
||||
|
||||
@@ -439,10 +439,14 @@
|
||||
"success_requests": "成功请求",
|
||||
"failed_requests": "失败请求",
|
||||
"total_tokens": "总Token数",
|
||||
"cached_tokens": "缓存 Token 数",
|
||||
"reasoning_tokens": "思考 Token 数",
|
||||
"rpm_30m": "RPM(近30分钟)",
|
||||
"tpm_30m": "TPM(近30分钟)",
|
||||
"cached_tokens": "缓存 Tokens",
|
||||
"reasoning_tokens": "思考 Tokens",
|
||||
"rpm_30m": "RPM",
|
||||
"tpm_30m": "TPM",
|
||||
"rate_30m": "近30分钟速率",
|
||||
"model_name": "模型名称",
|
||||
"model_price_settings": "模型价格设置",
|
||||
"saved_prices": "已保存的价格",
|
||||
"requests_trend": "请求趋势",
|
||||
"tokens_trend": "Token 使用趋势",
|
||||
"api_details": "API 详细统计",
|
||||
|
||||
@@ -1,24 +1,53 @@
|
||||
@use '../styles/variables' as *;
|
||||
@use '../styles/mixins' as *;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
.errorBox {
|
||||
padding: $spacing-md;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: $radius-md;
|
||||
color: var(--danger-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
// Stats Grid
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -30,20 +59,345 @@
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.label {
|
||||
.statHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 24px;
|
||||
.statValue {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.statValueRow {
|
||||
display: flex;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.statValueSmall {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statValueLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statValueNum {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.statMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statSuccess {
|
||||
color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.statFailure {
|
||||
color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
.statNeutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statHint {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// API List
|
||||
.apiList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.apiItem {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.apiHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.apiInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.apiEndpoint {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.apiStats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.apiBadge {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-left: $spacing-sm;
|
||||
}
|
||||
|
||||
.apiModels {
|
||||
padding: $spacing-md;
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 0;
|
||||
padding-top: $spacing-md;
|
||||
}
|
||||
|
||||
.modelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: $radius-sm;
|
||||
font-size: 13px;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.modelName {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.modelStat {
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
|
||||
@include mobile {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
// Table
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
|
||||
th, td {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.modelCell {
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// Pricing Section
|
||||
.pricingSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.priceForm {
|
||||
padding: $spacing-md;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.pricesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.pricesTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pricesGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.priceItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--border-color);
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.priceInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.priceModel {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.priceMeta {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.priceActions {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Chart Section (for future use)
|
||||
.chartSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -68,4 +422,68 @@
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.periodButtons {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
// Chart Line Controls
|
||||
.chartLineControls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-lg;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.chartLineList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chartLineItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.chartLineLabel {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.chartLineActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chartLineCount {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chartLineHint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: $spacing-sm 0 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import type { KeyStats } from '@/utils/usage';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateTokenBreakdown,
|
||||
calculateRecentPerMinuteRates,
|
||||
calculateTotalCost,
|
||||
getModelNamesFromUsage,
|
||||
getApiStats,
|
||||
getModelStats,
|
||||
loadModelPrices,
|
||||
saveModelPrices,
|
||||
buildChartData,
|
||||
type ModelPrice
|
||||
} from '@/utils/usage';
|
||||
import styles from './UsagePage.module.scss';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface UsagePayload {
|
||||
total_requests?: number;
|
||||
success_requests?: number;
|
||||
failed_requests?: number;
|
||||
success_count?: number;
|
||||
failure_count?: number;
|
||||
total_tokens?: number;
|
||||
cached_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
rpm_30m?: number;
|
||||
tpm_30m?: number;
|
||||
apis?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -23,86 +60,547 @@ export function UsagePage() {
|
||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [keyStats, setKeyStats] = useState<KeyStats | null>(null);
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
|
||||
const loadUsage = async () => {
|
||||
// Model price form state
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [promptPrice, setPromptPrice] = useState('');
|
||||
const [completionPrice, setCompletionPrice] = useState('');
|
||||
|
||||
// Expanded sections
|
||||
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||
|
||||
// Chart state
|
||||
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
||||
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
||||
const [chartLines, setChartLines] = useState<string[]>(['all']);
|
||||
const MAX_CHART_LINES = 9;
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = data?.usage ?? data;
|
||||
setUsage(payload);
|
||||
const stats = await usageApi.getKeyStats(payload);
|
||||
setKeyStats(stats);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('usage_stats.loading_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
}, []);
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [loadUsage]);
|
||||
|
||||
const overviewItems = [
|
||||
{ label: t('usage_stats.total_requests'), value: usage?.total_requests },
|
||||
{ label: t('usage_stats.success_requests'), value: usage?.success_requests },
|
||||
{ label: t('usage_stats.failed_requests'), value: usage?.failed_requests },
|
||||
{ label: t('usage_stats.total_tokens'), value: usage?.total_tokens },
|
||||
{ label: t('usage_stats.cached_tokens'), value: usage?.cached_tokens },
|
||||
{ label: t('usage_stats.reasoning_tokens'), value: usage?.reasoning_tokens },
|
||||
{ label: t('usage_stats.rpm_30m'), value: usage?.rpm_30m },
|
||||
{ label: t('usage_stats.tpm_30m'), value: usage?.tpm_30m }
|
||||
];
|
||||
// Calculate derived data
|
||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||
const rateStats = usage ? calculateRecentPerMinuteRates(30, usage) : { rpm: 0, tpm: 0 };
|
||||
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
|
||||
const modelNames = usage ? getModelNamesFromUsage(usage) : [];
|
||||
const apiStats = usage ? getApiStats(usage, modelPrices) : [];
|
||||
const modelStats = usage ? getModelStats(usage, modelPrices) : [];
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
// Build chart data
|
||||
const requestsChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
||||
}, [usage, requestsPeriod, chartLines]);
|
||||
|
||||
const tokensChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
||||
}, [usage, tokensPeriod, chartLines]);
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top' as const,
|
||||
align: 'start' as const,
|
||||
labels: {
|
||||
usePointStyle: true
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.35,
|
||||
borderWidth: 2
|
||||
},
|
||||
point: {
|
||||
borderWidth: 2,
|
||||
radius: 4
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Chart line management
|
||||
const handleAddChartLine = () => {
|
||||
if (chartLines.length >= MAX_CHART_LINES) return;
|
||||
const unusedModel = modelNames.find(m => !chartLines.includes(m));
|
||||
if (unusedModel) {
|
||||
setChartLines([...chartLines, unusedModel]);
|
||||
} else {
|
||||
setChartLines([...chartLines, 'all']);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveChartLine = (index: number) => {
|
||||
if (chartLines.length <= 1) return;
|
||||
const newLines = [...chartLines];
|
||||
newLines.splice(index, 1);
|
||||
setChartLines(newLines);
|
||||
};
|
||||
|
||||
const handleChartLineChange = (index: number, value: string) => {
|
||||
const newLines = [...chartLines];
|
||||
newLines[index] = value;
|
||||
setChartLines(newLines);
|
||||
};
|
||||
|
||||
// Handle model price save
|
||||
const handleSavePrice = () => {
|
||||
if (!selectedModel) return;
|
||||
const prompt = parseFloat(promptPrice) || 0;
|
||||
const completion = parseFloat(completionPrice) || 0;
|
||||
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion } };
|
||||
setModelPrices(newPrices);
|
||||
saveModelPrices(newPrices);
|
||||
setSelectedModel('');
|
||||
setPromptPrice('');
|
||||
setCompletionPrice('');
|
||||
};
|
||||
|
||||
// Handle model price delete
|
||||
const handleDeletePrice = (model: string) => {
|
||||
const newPrices = { ...modelPrices };
|
||||
delete newPrices[model];
|
||||
setModelPrices(newPrices);
|
||||
saveModelPrices(newPrices);
|
||||
};
|
||||
|
||||
// Handle edit price
|
||||
const handleEditPrice = (model: string) => {
|
||||
const price = modelPrices[model];
|
||||
setSelectedModel(model);
|
||||
setPromptPrice(price?.prompt?.toString() || '');
|
||||
setCompletionPrice(price?.completion?.toString() || '');
|
||||
};
|
||||
|
||||
// Toggle API expansion
|
||||
const toggleApiExpand = (endpoint: string) => {
|
||||
setExpandedApis(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(endpoint)) {
|
||||
newSet.delete(endpoint);
|
||||
} else {
|
||||
newSet.add(endpoint);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Card
|
||||
title={t('usage_stats.title')}
|
||||
extra={
|
||||
<Button variant="secondary" size="sm" onClick={loadUsage} disabled={loading}>
|
||||
{t('usage_stats.refresh')}
|
||||
</Button>
|
||||
}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadUsage}
|
||||
disabled={loading}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : (
|
||||
<div className="grid cols-2">
|
||||
{overviewItems.map((item) => (
|
||||
<div key={item.label} className="stat-card">
|
||||
<div className="stat-label">{item.label}</div>
|
||||
<div className="stat-value">{item.value ?? '-'}</div>
|
||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
{/* Stats Overview Cards */}
|
||||
<div className={styles.statsGrid}>
|
||||
{/* Total Requests Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statHeader}>
|
||||
<span className={styles.statIcon}>📊</span>
|
||||
<span className={styles.statLabel}>{t('usage_stats.total_requests')}</span>
|
||||
</div>
|
||||
<div className={styles.statValue}>
|
||||
{loading ? '-' : (usage?.total_requests ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className={styles.statMeta}>
|
||||
<span className={styles.statSuccess}>
|
||||
✓ {t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||
</span>
|
||||
<span className={styles.statFailure}>
|
||||
✗ {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Tokens Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statHeader}>
|
||||
<span className={styles.statIcon}>🔤</span>
|
||||
<span className={styles.statLabel}>{t('usage_stats.total_tokens')}</span>
|
||||
</div>
|
||||
<div className={styles.statValue}>
|
||||
{loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||
</div>
|
||||
<div className={styles.statMeta}>
|
||||
<span className={styles.statNeutral}>
|
||||
💾 {t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||
</span>
|
||||
<span className={styles.statNeutral}>
|
||||
🧠 {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RPM/TPM Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statHeader}>
|
||||
<span className={styles.statIcon}>⚡</span>
|
||||
<span className={styles.statLabel}>{t('usage_stats.rate_30m')}</span>
|
||||
</div>
|
||||
<div className={styles.statValueRow}>
|
||||
<div className={styles.statValueSmall}>
|
||||
<span className={styles.statValueLabel}>{t('usage_stats.rpm_30m')}</span>
|
||||
<span className={styles.statValueNum}>{loading ? '-' : formatPerMinuteValue(rateStats.rpm)}</span>
|
||||
</div>
|
||||
<div className={styles.statValueSmall}>
|
||||
<span className={styles.statValueLabel}>{t('usage_stats.tpm_30m')}</span>
|
||||
<span className={styles.statValueNum}>{loading ? '-' : formatPerMinuteValue(rateStats.tpm)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Cost Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statHeader}>
|
||||
<span className={styles.statIcon}>💰</span>
|
||||
<span className={styles.statLabel}>{t('usage_stats.total_cost')}</span>
|
||||
</div>
|
||||
<div className={styles.statValue}>
|
||||
{loading ? '-' : hasPrices ? formatUsd(totalCost) : '--'}
|
||||
</div>
|
||||
{!hasPrices && (
|
||||
<div className={styles.statMeta}>
|
||||
<span className={styles.statHint}>{t('usage_stats.cost_need_price')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart Line Selection */}
|
||||
<Card title={t('usage_stats.chart_line_actions_label')}>
|
||||
<div className={styles.chartLineControls}>
|
||||
<div className={styles.chartLineList}>
|
||||
{chartLines.map((line, index) => (
|
||||
<div key={index} className={styles.chartLineItem}>
|
||||
<span className={styles.chartLineLabel}>
|
||||
{t(`usage_stats.chart_line_label_${index + 1}`)}:
|
||||
</span>
|
||||
<select
|
||||
value={line}
|
||||
onChange={(e) => handleChartLineChange(index, e.target.value)}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
{chartLines.length > 1 && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveChartLine(index)}
|
||||
>
|
||||
{t('usage_stats.chart_line_delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.chartLineActions}>
|
||||
<span className={styles.chartLineCount}>
|
||||
{chartLines.length}/{MAX_CHART_LINES}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleAddChartLine}
|
||||
disabled={chartLines.length >= MAX_CHART_LINES}
|
||||
>
|
||||
{t('usage_stats.chart_line_add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
|
||||
</Card>
|
||||
|
||||
{/* Requests Chart */}
|
||||
<Card
|
||||
title={t('usage_stats.requests_trend')}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={requestsPeriod === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setRequestsPeriod('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={requestsPeriod === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setRequestsPeriod('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : requestsChartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line data={requestsChartData} options={chartOptions} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Tokens Chart */}
|
||||
<Card
|
||||
title={t('usage_stats.tokens_trend')}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={tokensPeriod === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setTokensPeriod('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tokensPeriod === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setTokensPeriod('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : tokensChartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<Line data={tokensChartData} options={chartOptions} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* API Key Statistics */}
|
||||
<Card title={t('usage_stats.api_details')}>
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : keyStats && Object.keys(keyStats.bySource || {}).length ? (
|
||||
<div className="table">
|
||||
<div className="table-header">
|
||||
<div>{t('usage_stats.api_endpoint')}</div>
|
||||
<div>{t('stats.success')}</div>
|
||||
<div>{t('stats.failure')}</div>
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : apiStats.length > 0 ? (
|
||||
<div className={styles.apiList}>
|
||||
{apiStats.map((api) => (
|
||||
<div key={api.endpoint} className={styles.apiItem}>
|
||||
<div
|
||||
className={styles.apiHeader}
|
||||
onClick={() => toggleApiExpand(api.endpoint)}
|
||||
>
|
||||
<div className={styles.apiInfo}>
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
Tokens: {formatTokensInMillions(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Object.entries(keyStats.bySource || {}).map(([source, bucket]) => (
|
||||
<div key={source} className="table-row">
|
||||
<div className="cell item-subtitle">{source}</div>
|
||||
<div className="cell">{bucket.success}</div>
|
||||
<div className="cell">{bucket.failure}</div>
|
||||
</div>
|
||||
<span className={styles.expandIcon}>
|
||||
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedApis.has(api.endpoint) && (
|
||||
<div className={styles.apiModels}>
|
||||
{Object.entries(api.models).map(([model, stats]) => (
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>{stats.requests} {t('usage_stats.requests_count')}</span>
|
||||
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hint">{t('usage_stats.no_data')}</div>
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Model Statistics */}
|
||||
<Card title={t('usage_stats.models')}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : modelStats.length > 0 ? (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.model_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.tokens_count')}</th>
|
||||
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelStats.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>{stat.requests.toLocaleString()}</td>
|
||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Model Pricing Configuration */}
|
||||
<Card title={t('usage_stats.model_price_settings')}>
|
||||
<div className={styles.pricingSection}>
|
||||
{/* Price Form */}
|
||||
<div className={styles.priceForm}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_name')}</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => {
|
||||
setSelectedModel(e.target.value);
|
||||
const price = modelPrices[e.target.value];
|
||||
if (price) {
|
||||
setPromptPrice(price.prompt.toString());
|
||||
setCompletionPrice(price.completion.toString());
|
||||
} else {
|
||||
setPromptPrice('');
|
||||
setCompletionPrice('');
|
||||
}
|
||||
}}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={promptPrice}
|
||||
onChange={(e) => setPromptPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={completionPrice}
|
||||
onChange={(e) => setCompletionPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSavePrice}
|
||||
disabled={!selectedModel}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved Prices List */}
|
||||
<div className={styles.pricesList}>
|
||||
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
|
||||
{Object.keys(modelPrices).length > 0 ? (
|
||||
<div className={styles.pricesGrid}>
|
||||
{Object.entries(modelPrices).map(([model, price]) => (
|
||||
<div key={model} className={styles.priceItem}>
|
||||
<div className={styles.priceInfo}>
|
||||
<span className={styles.priceModel}>{model}</span>
|
||||
<div className={styles.priceMeta}>
|
||||
<span>{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M</span>
|
||||
<span>{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.priceActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleEditPrice(model)}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeletePrice(model)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,32 +2,208 @@
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.main-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle-header {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
margin-right: $spacing-md;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
.brand-full,
|
||||
.brand-abbr {
|
||||
display: inline-block;
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.brand-full {
|
||||
max-width: 320px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.brand-abbr {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(20px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
.brand-full {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.brand-abbr {
|
||||
position: relative;
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
// 移动端:禁用动画,只显示缩写
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
margin-right: $spacing-sm;
|
||||
cursor: default;
|
||||
|
||||
.brand-full,
|
||||
.brand-abbr {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.brand-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-abbr {
|
||||
position: relative;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
color: var(--text-secondary);
|
||||
min-width: 0;
|
||||
|
||||
.base {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-primary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: $spacing-lg $spacing-md;
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
transition: width $transition-normal, transform $transition-normal;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 57px); // 减去顶栏高度
|
||||
|
||||
&.collapsed {
|
||||
width: 60px;
|
||||
padding: $spacing-lg $spacing-sm;
|
||||
|
||||
.brand {
|
||||
text-align: center;
|
||||
}
|
||||
padding: $spacing-md $spacing-sm;
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
@@ -35,12 +211,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -92,29 +262,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
margin-top: auto;
|
||||
padding: $spacing-sm;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
z-index: $z-dropdown;
|
||||
left: 0;
|
||||
top: 0;
|
||||
top: 56px;
|
||||
bottom: 0;
|
||||
transform: translateX(-100%);
|
||||
box-shadow: $shadow-lg;
|
||||
@@ -130,37 +282,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.base {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 57px); // 减去顶栏高度
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -169,6 +292,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -15,6 +15,50 @@ export interface KeyStats {
|
||||
byAuthIndex: Record<string, KeyStatBucket>;
|
||||
}
|
||||
|
||||
export interface TokenBreakdown {
|
||||
cachedTokens: number;
|
||||
reasoningTokens: number;
|
||||
}
|
||||
|
||||
export interface RateStats {
|
||||
rpm: number;
|
||||
tpm: number;
|
||||
windowMinutes: number;
|
||||
requestCount: number;
|
||||
tokenCount: number;
|
||||
}
|
||||
|
||||
export interface ModelPrice {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
}
|
||||
|
||||
export interface UsageDetail {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
auth_index: number;
|
||||
tokens: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
reasoning_tokens: number;
|
||||
cached_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
failed: boolean;
|
||||
__modelName?: string;
|
||||
}
|
||||
|
||||
export interface ApiStats {
|
||||
endpoint: string;
|
||||
totalRequests: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
models: Record<string, { requests: number; tokens: number }>;
|
||||
}
|
||||
|
||||
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
||||
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
||||
|
||||
const normalizeAuthIndex = (value: any) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toString();
|
||||
@@ -70,6 +114,580 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) =>
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 tokens 为百万单位
|
||||
*/
|
||||
export function formatTokensInMillions(value: number): string {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return '0.00M';
|
||||
}
|
||||
return `${(num / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化每分钟数值
|
||||
*/
|
||||
export function formatPerMinuteValue(value: number): string {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return '0.00';
|
||||
}
|
||||
const abs = Math.abs(num);
|
||||
if (abs >= 1000) {
|
||||
return Math.round(num).toLocaleString();
|
||||
}
|
||||
if (abs >= 100) {
|
||||
return num.toFixed(0);
|
||||
}
|
||||
if (abs >= 10) {
|
||||
return num.toFixed(1);
|
||||
}
|
||||
return num.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化紧凑数字
|
||||
*/
|
||||
export function formatCompactNumber(value: number): string {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return '0';
|
||||
}
|
||||
const abs = Math.abs(num);
|
||||
if (abs >= 1_000_000) {
|
||||
return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (abs >= 1_000) {
|
||||
return `${(num / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return abs >= 1 ? num.toFixed(0) : num.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化美元
|
||||
*/
|
||||
export function formatUsd(value: number): string {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return '$0.00';
|
||||
}
|
||||
const fixed = num.toFixed(2);
|
||||
const parts = Number(fixed).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
return `$${parts}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从使用数据中收集所有请求明细
|
||||
*/
|
||||
export function collectUsageDetails(usageData: any): UsageDetail[] {
|
||||
if (!usageData) {
|
||||
return [];
|
||||
}
|
||||
const apis = usageData.apis || {};
|
||||
const details: UsageDetail[] = [];
|
||||
Object.values(apis as Record<string, any>).forEach((apiEntry) => {
|
||||
const models = apiEntry?.models || {};
|
||||
Object.entries(models as Record<string, any>).forEach(([modelName, modelEntry]) => {
|
||||
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
||||
modelDetails.forEach((detail: any) => {
|
||||
if (detail && detail.timestamp) {
|
||||
details.push({
|
||||
...detail,
|
||||
__modelName: modelName
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单条明细提取总 tokens
|
||||
*/
|
||||
export function extractTotalTokens(detail: any): number {
|
||||
const tokens = detail?.tokens || {};
|
||||
if (typeof tokens.total_tokens === 'number') {
|
||||
return tokens.total_tokens;
|
||||
}
|
||||
const tokenKeys = ['input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens'];
|
||||
return tokenKeys.reduce((sum, key) => {
|
||||
const value = tokens[key];
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 token 分类统计
|
||||
*/
|
||||
export function calculateTokenBreakdown(usageData: any): TokenBreakdown {
|
||||
const details = collectUsageDetails(usageData);
|
||||
if (!details.length) {
|
||||
return { cachedTokens: 0, reasoningTokens: 0 };
|
||||
}
|
||||
|
||||
let cachedTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
|
||||
details.forEach(detail => {
|
||||
const tokens = detail?.tokens || {};
|
||||
if (typeof tokens.cached_tokens === 'number') {
|
||||
cachedTokens += tokens.cached_tokens;
|
||||
}
|
||||
if (typeof tokens.reasoning_tokens === 'number') {
|
||||
reasoningTokens += tokens.reasoning_tokens;
|
||||
}
|
||||
});
|
||||
|
||||
return { cachedTokens, reasoningTokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算最近 N 分钟的 RPM/TPM
|
||||
*/
|
||||
export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageData: any): RateStats {
|
||||
const details = collectUsageDetails(usageData);
|
||||
const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 30;
|
||||
|
||||
if (!details.length) {
|
||||
return { rpm: 0, tpm: 0, windowMinutes: effectiveWindow, requestCount: 0, tokenCount: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowStart = now - effectiveWindow * 60 * 1000;
|
||||
let requestCount = 0;
|
||||
let tokenCount = 0;
|
||||
|
||||
details.forEach(detail => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||||
return;
|
||||
}
|
||||
requestCount += 1;
|
||||
tokenCount += extractTotalTokens(detail);
|
||||
});
|
||||
|
||||
const denominator = effectiveWindow > 0 ? effectiveWindow : 1;
|
||||
return {
|
||||
rpm: requestCount / denominator,
|
||||
tpm: tokenCount / denominator,
|
||||
windowMinutes: effectiveWindow,
|
||||
requestCount,
|
||||
tokenCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从使用数据获取模型名称列表
|
||||
*/
|
||||
export function getModelNamesFromUsage(usageData: any): string[] {
|
||||
if (!usageData) {
|
||||
return [];
|
||||
}
|
||||
const apis = usageData.apis || {};
|
||||
const names = new Set<string>();
|
||||
Object.values(apis as Record<string, any>).forEach(apiEntry => {
|
||||
const models = apiEntry?.models || {};
|
||||
Object.keys(models).forEach(modelName => {
|
||||
if (modelName) {
|
||||
names.add(modelName);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算成本数据
|
||||
*/
|
||||
export function calculateCost(detail: any, modelPrices: Record<string, ModelPrice>): number {
|
||||
const modelName = detail.__modelName || '';
|
||||
const price = modelPrices[modelName];
|
||||
if (!price) {
|
||||
return 0;
|
||||
}
|
||||
const tokens = detail?.tokens || {};
|
||||
const promptTokens = Number(tokens.input_tokens) || 0;
|
||||
const completionTokens = Number(tokens.output_tokens) || 0;
|
||||
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
|
||||
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||||
const total = promptCost + completionCost;
|
||||
return Number.isFinite(total) && total > 0 ? total : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算总成本
|
||||
*/
|
||||
export function calculateTotalCost(usageData: any, modelPrices: Record<string, ModelPrice>): number {
|
||||
const details = collectUsageDetails(usageData);
|
||||
if (!details.length || !Object.keys(modelPrices).length) {
|
||||
return 0;
|
||||
}
|
||||
return details.reduce((sum, detail) => sum + calculateCost(detail, modelPrices), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载模型价格
|
||||
*/
|
||||
export function loadModelPrices(): Record<string, ModelPrice> {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
const raw = localStorage.getItem(MODEL_PRICE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {};
|
||||
}
|
||||
const normalized: Record<string, ModelPrice> = {};
|
||||
Object.entries(parsed).forEach(([model, price]: [string, any]) => {
|
||||
if (!model) return;
|
||||
const prompt = Number(price?.prompt);
|
||||
const completion = Number(price?.completion);
|
||||
if (!Number.isFinite(prompt) && !Number.isFinite(completion)) {
|
||||
return;
|
||||
}
|
||||
normalized[model] = {
|
||||
prompt: Number.isFinite(prompt) && prompt >= 0 ? prompt : 0,
|
||||
completion: Number.isFinite(completion) && completion >= 0 ? completion : 0
|
||||
};
|
||||
});
|
||||
return normalized;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模型价格到 localStorage
|
||||
*/
|
||||
export function saveModelPrices(prices: Record<string, ModelPrice>): void {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(MODEL_PRICE_STORAGE_KEY, JSON.stringify(prices));
|
||||
} catch {
|
||||
console.warn('保存模型价格失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 API 统计数据
|
||||
*/
|
||||
export function getApiStats(usageData: any, modelPrices: Record<string, ModelPrice>): ApiStats[] {
|
||||
if (!usageData?.apis) {
|
||||
return [];
|
||||
}
|
||||
const apis = usageData.apis;
|
||||
const result: ApiStats[] = [];
|
||||
|
||||
Object.entries(apis as Record<string, any>).forEach(([endpoint, apiData]) => {
|
||||
const models: Record<string, { requests: number; tokens: number }> = {};
|
||||
let totalCost = 0;
|
||||
|
||||
const modelsData = apiData?.models || {};
|
||||
Object.entries(modelsData as Record<string, any>).forEach(([modelName, modelData]) => {
|
||||
models[modelName] = {
|
||||
requests: modelData.total_requests || 0,
|
||||
tokens: modelData.total_tokens || 0
|
||||
};
|
||||
|
||||
const price = modelPrices[modelName];
|
||||
if (price) {
|
||||
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
||||
details.forEach((detail: any) => {
|
||||
const tokens = detail?.tokens || {};
|
||||
const promptTokens = Number(tokens.input_tokens) || 0;
|
||||
const completionTokens = Number(tokens.output_tokens) || 0;
|
||||
const cost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0) +
|
||||
(completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||||
if (Number.isFinite(cost) && cost > 0) {
|
||||
totalCost += cost;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
result.push({
|
||||
endpoint: maskUsageSensitiveValue(endpoint) || endpoint,
|
||||
totalRequests: apiData.total_requests || 0,
|
||||
totalTokens: apiData.total_tokens || 0,
|
||||
totalCost,
|
||||
models
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型统计数据
|
||||
*/
|
||||
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
|
||||
model: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}> {
|
||||
if (!usageData?.apis) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const modelMap = new Map<string, { requests: number; tokens: number; cost: number }>();
|
||||
|
||||
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
|
||||
const models = apiData?.models || {};
|
||||
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
|
||||
const existing = modelMap.get(modelName) || { requests: 0, tokens: 0, cost: 0 };
|
||||
existing.requests += modelData.total_requests || 0;
|
||||
existing.tokens += modelData.total_tokens || 0;
|
||||
|
||||
const price = modelPrices[modelName];
|
||||
if (price) {
|
||||
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
||||
details.forEach((detail: any) => {
|
||||
const tokens = detail?.tokens || {};
|
||||
const promptTokens = Number(tokens.input_tokens) || 0;
|
||||
const completionTokens = Number(tokens.output_tokens) || 0;
|
||||
const cost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0) +
|
||||
(completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||||
if (Number.isFinite(cost) && cost > 0) {
|
||||
existing.cost += cost;
|
||||
}
|
||||
});
|
||||
}
|
||||
modelMap.set(modelName, existing);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(modelMap.entries())
|
||||
.map(([model, stats]) => ({ model, ...stats }))
|
||||
.sort((a, b) => b.requests - a.requests);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化小时标签
|
||||
*/
|
||||
export function formatHourLabel(date: Date): string {
|
||||
if (!(date instanceof Date)) {
|
||||
return '';
|
||||
}
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const hour = date.getHours().toString().padStart(2, '0');
|
||||
return `${month}-${day} ${hour}:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期标签
|
||||
*/
|
||||
export function formatDayLabel(date: Date): string {
|
||||
if (!(date instanceof Date)) {
|
||||
return '';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建小时级别的数据序列
|
||||
*/
|
||||
export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): {
|
||||
labels: string[];
|
||||
dataByModel: Map<string, number[]>;
|
||||
hasData: boolean;
|
||||
} {
|
||||
const hourMs = 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const currentHour = new Date(now);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
const earliestBucket = new Date(currentHour);
|
||||
earliestBucket.setHours(earliestBucket.getHours() - 23);
|
||||
const earliestTime = earliestBucket.getTime();
|
||||
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const bucketStart = earliestTime + i * hourMs;
|
||||
labels.push(formatHourLabel(new Date(bucketStart)));
|
||||
}
|
||||
|
||||
const details = collectUsageDetails(usageData);
|
||||
const dataByModel = new Map<string, number[]>();
|
||||
let hasData = false;
|
||||
|
||||
if (!details.length) {
|
||||
return { labels, dataByModel, hasData };
|
||||
}
|
||||
|
||||
details.forEach(detail => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
const bucketStart = normalized.getTime();
|
||||
const lastBucketTime = earliestTime + (labels.length - 1) * hourMs;
|
||||
if (bucketStart < earliestTime || bucketStart > lastBucketTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs);
|
||||
if (bucketIndex < 0 || bucketIndex >= labels.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelName = detail.__modelName || 'Unknown';
|
||||
if (!dataByModel.has(modelName)) {
|
||||
dataByModel.set(modelName, new Array(labels.length).fill(0));
|
||||
}
|
||||
|
||||
const bucketValues = dataByModel.get(modelName)!;
|
||||
if (metric === 'tokens') {
|
||||
bucketValues[bucketIndex] += extractTotalTokens(detail);
|
||||
} else {
|
||||
bucketValues[bucketIndex] += 1;
|
||||
}
|
||||
hasData = true;
|
||||
});
|
||||
|
||||
return { labels, dataByModel, hasData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建日级别的数据序列
|
||||
*/
|
||||
export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): {
|
||||
labels: string[];
|
||||
dataByModel: Map<string, number[]>;
|
||||
hasData: boolean;
|
||||
} {
|
||||
const details = collectUsageDetails(usageData);
|
||||
const valuesByModel = new Map<string, Map<string, number>>();
|
||||
const labelsSet = new Set<string>();
|
||||
let hasData = false;
|
||||
|
||||
if (!details.length) {
|
||||
return { labels: [], dataByModel: new Map(), hasData };
|
||||
}
|
||||
|
||||
details.forEach(detail => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return;
|
||||
}
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelName = detail.__modelName || 'Unknown';
|
||||
if (!valuesByModel.has(modelName)) {
|
||||
valuesByModel.set(modelName, new Map());
|
||||
}
|
||||
const modelDayMap = valuesByModel.get(modelName)!;
|
||||
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
|
||||
modelDayMap.set(dayLabel, (modelDayMap.get(dayLabel) || 0) + increment);
|
||||
labelsSet.add(dayLabel);
|
||||
hasData = true;
|
||||
});
|
||||
|
||||
const labels = Array.from(labelsSet).sort();
|
||||
const dataByModel = new Map<string, number[]>();
|
||||
valuesByModel.forEach((dayMap, modelName) => {
|
||||
const series = labels.map(label => dayMap.get(label) || 0);
|
||||
dataByModel.set(modelName, series);
|
||||
});
|
||||
|
||||
return { labels, dataByModel, hasData };
|
||||
}
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
fill: boolean;
|
||||
tension: number;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
labels: string[];
|
||||
datasets: ChartDataset[];
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
{ borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' },
|
||||
{ borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' },
|
||||
{ borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' },
|
||||
{ borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.15)' },
|
||||
{ borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' },
|
||||
{ borderColor: '#06b6d4', backgroundColor: 'rgba(6, 182, 212, 0.15)' },
|
||||
{ borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' },
|
||||
{ borderColor: '#84cc16', backgroundColor: 'rgba(132, 204, 22, 0.15)' },
|
||||
{ borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 构建图表数据
|
||||
*/
|
||||
export function buildChartData(
|
||||
usageData: any,
|
||||
period: 'hour' | 'day' = 'day',
|
||||
metric: 'requests' | 'tokens' = 'requests',
|
||||
selectedModels: string[] = []
|
||||
): ChartData {
|
||||
const baseSeries = period === 'hour'
|
||||
? buildHourlySeriesByModel(usageData, metric)
|
||||
: buildDailySeriesByModel(usageData, metric);
|
||||
|
||||
const { labels, dataByModel } = baseSeries;
|
||||
|
||||
// Build "All" series as sum of all models
|
||||
const getAllSeries = (): number[] => {
|
||||
const summed = new Array(labels.length).fill(0);
|
||||
dataByModel.forEach(values => {
|
||||
values.forEach((value, idx) => {
|
||||
summed[idx] = (summed[idx] || 0) + value;
|
||||
});
|
||||
});
|
||||
return summed;
|
||||
};
|
||||
|
||||
// Determine which models to show
|
||||
const modelsToShow = selectedModels.length > 0 ? selectedModels : ['all'];
|
||||
|
||||
const datasets: ChartDataset[] = modelsToShow.map((model, index) => {
|
||||
const isAll = model === 'all';
|
||||
const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0));
|
||||
const colorIndex = index % CHART_COLORS.length;
|
||||
const style = CHART_COLORS[colorIndex];
|
||||
|
||||
return {
|
||||
label: isAll ? 'All Models' : model,
|
||||
data,
|
||||
borderColor: style.borderColor,
|
||||
backgroundColor: style.backgroundColor,
|
||||
fill: false,
|
||||
tension: 0.35
|
||||
};
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据 usage 数据计算密钥使用统计
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user