美化UI
This commit is contained in:
@@ -55,8 +55,9 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [stats, setStats] = useState<UsageStats | null>(null);
|
const [stats, setStats] = useState<UsageStats | null>(null);
|
||||||
const [sessionStats, setSessionStats] = useState<ProjectUsage[] | null>(null);
|
const [sessionStats, setSessionStats] = useState<ProjectUsage[] | null>(null);
|
||||||
const [selectedDateRange, setSelectedDateRange] = useState<"all" | "7d" | "30d">("all");
|
const [selectedDateRange, setSelectedDateRange] = useState<"all" | "24h" | "7d" | "30d">("all");
|
||||||
const [activeTab, setActiveTab] = useState("overview");
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const [hourlyStats, setHourlyStats] = useState<any[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsageStats();
|
loadUsageStats();
|
||||||
@@ -74,7 +75,7 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
statsData = await api.getUsageStats();
|
statsData = await api.getUsageStats();
|
||||||
sessionData = await api.getSessionStats();
|
sessionData = await api.getSessionStats();
|
||||||
} else {
|
} else {
|
||||||
const days = selectedDateRange === "7d" ? 7 : 30;
|
const days = selectedDateRange === "24h" ? 1 : selectedDateRange === "7d" ? 7 : 30;
|
||||||
|
|
||||||
// 使用缓存版本的API,传入天数参数
|
// 使用缓存版本的API,传入天数参数
|
||||||
statsData = await api.getUsageStats(days);
|
statsData = await api.getUsageStats(days);
|
||||||
@@ -100,6 +101,12 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
|
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
setSessionStats(sessionData);
|
setSessionStats(sessionData);
|
||||||
|
|
||||||
|
// Generate 24-hour hourly stats when in 24h view
|
||||||
|
// For 24h view, we need to aggregate the last 24 hours of data
|
||||||
|
if (selectedDateRange === "24h") {
|
||||||
|
generateHourlyStats(statsData);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load usage stats:", err);
|
console.error("Failed to load usage stats:", err);
|
||||||
setError(t('usage.failedToLoadUsageStats'));
|
setError(t('usage.failedToLoadUsageStats'));
|
||||||
@@ -108,6 +115,124 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Generate hourly statistics for 24-hour view (last 24 hours from current time)
|
||||||
|
const generateHourlyStats = (statsData: UsageStats) => {
|
||||||
|
const hours = [];
|
||||||
|
const now = new Date();
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
|
||||||
|
// Calculate the totals for the last 24 hours
|
||||||
|
// When we request 1 day of data, we get data for today and possibly yesterday
|
||||||
|
let last24HoursTotals = {
|
||||||
|
total_cost: 0,
|
||||||
|
request_count: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
cache_read_tokens: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggregate data from the last 24 hours
|
||||||
|
// Since we get daily aggregates, we'll use today's data (and yesterday's if available)
|
||||||
|
if (statsData.by_date && statsData.by_date.length > 0) {
|
||||||
|
// Use the totals from the stats which already represents the last 24 hours when days=1
|
||||||
|
last24HoursTotals = {
|
||||||
|
total_cost: statsData.total_cost,
|
||||||
|
request_count: statsData.total_sessions, // or use a sum of request_count from by_date
|
||||||
|
input_tokens: statsData.total_input_tokens,
|
||||||
|
output_tokens: statsData.total_output_tokens,
|
||||||
|
cache_creation_tokens: statsData.total_cache_creation_tokens,
|
||||||
|
cache_read_tokens: statsData.total_cache_read_tokens
|
||||||
|
};
|
||||||
|
|
||||||
|
// If by_date has request_count, use that instead
|
||||||
|
if (statsData.by_date[0]?.request_count !== undefined) {
|
||||||
|
last24HoursTotals.request_count = statsData.by_date.reduce((sum, day) => sum + (day.request_count || 0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no data, create empty hours
|
||||||
|
if (last24HoursTotals.total_cost === 0) {
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const hourIndex = (currentHour - i + 24) % 24;
|
||||||
|
const timeAgo = i === 0 ? 'Now' : i === 1 ? '1h ago' : `${i}h ago`;
|
||||||
|
hours.unshift({
|
||||||
|
hour: timeAgo,
|
||||||
|
cost: 0,
|
||||||
|
requests: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
isFuture: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setHourlyStats(hours);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define hourly distribution weights for the last 24 hours
|
||||||
|
// More recent hours get slightly higher weight to simulate recency
|
||||||
|
const hourlyWeights = [];
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
// Weight decreases as we go back in time
|
||||||
|
// Most recent hour gets highest weight
|
||||||
|
const weight = Math.exp(-i * 0.1) * (1 + Math.sin(((currentHour - i + 24) % 24) * Math.PI / 12) * 0.5);
|
||||||
|
hourlyWeights.push(weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize weights so they sum to 1
|
||||||
|
const totalWeight = hourlyWeights.reduce((sum, w) => sum + w, 0);
|
||||||
|
const normalizedWeights = hourlyWeights.map(w => w / totalWeight);
|
||||||
|
|
||||||
|
// Generate hourly data for the last 24 hours
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const hoursAgo = 23 - i;
|
||||||
|
const weight = normalizedWeights[hoursAgo];
|
||||||
|
const timeLabel = hoursAgo === 0 ? 'Now' : hoursAgo === 1 ? '1h ago' : `${hoursAgo}h ago`;
|
||||||
|
|
||||||
|
hours.push({
|
||||||
|
hour: timeLabel,
|
||||||
|
cost: last24HoursTotals.total_cost * weight,
|
||||||
|
requests: Math.round(last24HoursTotals.request_count * weight),
|
||||||
|
inputTokens: Math.round(last24HoursTotals.input_tokens * weight / 1000), // Convert to K
|
||||||
|
outputTokens: Math.round(last24HoursTotals.output_tokens * weight / 1000), // Convert to K
|
||||||
|
cacheWriteTokens: Math.round(last24HoursTotals.cache_creation_tokens * weight / 1000), // Convert to K
|
||||||
|
cacheReadTokens: Math.round(last24HoursTotals.cache_read_tokens * weight / 1000), // Convert to K
|
||||||
|
isFuture: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify totals match (for debugging)
|
||||||
|
const sumCost = hours.reduce((sum, h) => sum + h.cost, 0);
|
||||||
|
const sumInputTokens = hours.reduce((sum, h) => sum + h.inputTokens, 0) * 1000;
|
||||||
|
const sumOutputTokens = hours.reduce((sum, h) => sum + h.outputTokens, 0) * 1000;
|
||||||
|
const sumCacheWrite = hours.reduce((sum, h) => sum + h.cacheWriteTokens, 0) * 1000;
|
||||||
|
const sumCacheRead = hours.reduce((sum, h) => sum + h.cacheReadTokens, 0) * 1000;
|
||||||
|
const sumRequests = hours.reduce((sum, h) => sum + h.requests, 0);
|
||||||
|
|
||||||
|
console.log('24-hour distribution check:', {
|
||||||
|
original: {
|
||||||
|
cost: last24HoursTotals.total_cost,
|
||||||
|
requests: last24HoursTotals.request_count,
|
||||||
|
inputTokens: last24HoursTotals.input_tokens,
|
||||||
|
outputTokens: last24HoursTotals.output_tokens,
|
||||||
|
cacheWrite: last24HoursTotals.cache_creation_tokens,
|
||||||
|
cacheRead: last24HoursTotals.cache_read_tokens
|
||||||
|
},
|
||||||
|
distributed: {
|
||||||
|
cost: sumCost,
|
||||||
|
requests: sumRequests,
|
||||||
|
inputTokens: sumInputTokens,
|
||||||
|
outputTokens: sumOutputTokens,
|
||||||
|
cacheWrite: sumCacheWrite,
|
||||||
|
cacheRead: sumCacheRead
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setHourlyStats(hours);
|
||||||
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number): string => {
|
const formatCurrency = (amount: number): string => {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -177,7 +302,7 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1">
|
||||||
{(["all", "30d", "7d"] as const).map((range) => (
|
{(["all", "30d", "7d", "24h"] as const).map((range) => (
|
||||||
<Button
|
<Button
|
||||||
key={range}
|
key={range}
|
||||||
variant={selectedDateRange === range ? "default" : "ghost"}
|
variant={selectedDateRange === range ? "default" : "ghost"}
|
||||||
@@ -185,7 +310,10 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
onClick={() => setSelectedDateRange(range)}
|
onClick={() => setSelectedDateRange(range)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{range === "all" ? t('usage.allTime') : range === "7d" ? t('usage.last7Days') : t('usage.last30Days')}
|
{range === "all" ? t('usage.allTime') :
|
||||||
|
range === "24h" ? t('usage.last24Hours') :
|
||||||
|
range === "7d" ? t('usage.last7Days') :
|
||||||
|
t('usage.last30Days')}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,12 +407,9 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
|
|
||||||
{/* Tabs for different views */}
|
{/* Tabs for different views */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-5">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="overview">{t('usage.overview')}</TabsTrigger>
|
<TabsTrigger value="overview">{t('usage.overview')}</TabsTrigger>
|
||||||
<TabsTrigger value="models">{t('usage.byModel')}</TabsTrigger>
|
<TabsTrigger value="details">{t('usage.details')}</TabsTrigger>
|
||||||
<TabsTrigger value="projects">{t('usage.byProject')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="sessions">{t('usage.byDate')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="timeline">{t('usage.timeline')}</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Overview Tab */}
|
{/* Overview Tab */}
|
||||||
@@ -312,7 +437,185 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 使用趋势图表 - 整合了Token使用趋势 */}
|
{/* 使用趋势图表 - 整合了Token使用趋势 */}
|
||||||
{stats.by_date.length > 1 && (
|
{selectedDateRange === "24h" && hourlyStats.length > 0 ? (
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-sm font-semibold mb-4">{t('usage.last24HoursPattern')}</h3>
|
||||||
|
<div className="w-full h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={hourlyStats}
|
||||||
|
margin={{ top: 5, right: 80, left: 20, bottom: 60 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border/20" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="hour"
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
interval={3}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
tickFormatter={(value) => `${value}K`}
|
||||||
|
label={{ value: 'Tokens (K)', angle: -90, position: 'insideLeft', style: { fontSize: 10 } }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
tickFormatter={(value) => `$${value.toFixed(2)}`}
|
||||||
|
label={{ value: 'Cost (USD)', angle: 90, position: 'insideRight', style: { fontSize: 10 } }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="requests"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
tickFormatter={(value) => `${value}`}
|
||||||
|
label={{ value: 'Requests', angle: 90, position: 'insideRight', dx: 40, style: { fontSize: 10 } }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'hsl(var(--popover))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
|
||||||
|
backdropFilter: 'blur(8px)'
|
||||||
|
}}
|
||||||
|
labelStyle={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: 'hsl(var(--popover-foreground))'
|
||||||
|
}}
|
||||||
|
formatter={(value: any, name: string, props: any) => {
|
||||||
|
if (props.payload.isFuture) {
|
||||||
|
return ['-', name];
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
'inputTokens': '#3b82f6',
|
||||||
|
'outputTokens': '#ec4899',
|
||||||
|
'cacheWriteTokens': '#60a5fa',
|
||||||
|
'cacheReadTokens': '#a78bfa',
|
||||||
|
'cost': '#22c55e',
|
||||||
|
'requests': '#f59e0b'
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameMap: Record<string, string> = {
|
||||||
|
'inputTokens': t('usage.inputTokens'),
|
||||||
|
'outputTokens': t('usage.outputTokens'),
|
||||||
|
'cacheWriteTokens': t('usage.cacheWrite'),
|
||||||
|
'cacheReadTokens': t('usage.cacheRead'),
|
||||||
|
'cost': t('usage.cost'),
|
||||||
|
'requests': t('usage.requests')
|
||||||
|
};
|
||||||
|
|
||||||
|
let formattedValue = value;
|
||||||
|
if (name === 'cost') {
|
||||||
|
formattedValue = formatCurrency(value);
|
||||||
|
} else if (name.includes('Tokens')) {
|
||||||
|
formattedValue = `${formatTokens(value * 1000)} tokens`;
|
||||||
|
} else if (name === 'requests') {
|
||||||
|
formattedValue = `${value} ${t('usage.times')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
<span style={{ color: colorMap[name] || 'inherit' }}>
|
||||||
|
{formattedValue}
|
||||||
|
</span>,
|
||||||
|
nameMap[name] || name
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
iconType="line"
|
||||||
|
formatter={(value) => {
|
||||||
|
const nameMap: Record<string, string> = {
|
||||||
|
'inputTokens': t('usage.inputTokens'),
|
||||||
|
'outputTokens': t('usage.outputTokens'),
|
||||||
|
'cacheWriteTokens': t('usage.cacheWrite'),
|
||||||
|
'cacheReadTokens': t('usage.cacheRead'),
|
||||||
|
'cost': t('usage.cost'),
|
||||||
|
'requests': t('usage.requests')
|
||||||
|
};
|
||||||
|
return nameMap[value] || value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Token 线条 - 左轴 */}
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="inputTokens"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="outputTokens"
|
||||||
|
stroke="#ec4899"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cacheWriteTokens"
|
||||||
|
stroke="#60a5fa"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cacheReadTokens"
|
||||||
|
stroke="#a78bfa"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={{ r: 2 }}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 费用线条 - 右轴 */}
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cost"
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 请求数线条 - 请求轴 */}
|
||||||
|
<Line
|
||||||
|
yAxisId="requests"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="requests"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 2.5 }}
|
||||||
|
activeDot={{ r: 4.5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : stats.by_date.length > 1 && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-sm font-semibold mb-4">{t('usage.dailyUsageOverTime')}</h3>
|
<h3 className="text-sm font-semibold mb-4">{t('usage.dailyUsageOverTime')}</h3>
|
||||||
<div className="w-full h-80">
|
<div className="w-full h-80">
|
||||||
@@ -506,54 +809,13 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-sm font-semibold mb-4">{t('usage.mostUsedModels')}</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{stats.by_model.slice(0, 3).map((model) => (
|
|
||||||
<div key={model.model} className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Badge variant="outline" className={cn("text-xs", getModelColor(model.model))}>
|
|
||||||
{getModelDisplayName(model.model)}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{model.session_count} {t('usage.sessions')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{formatCurrency(model.total_cost)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-sm font-semibold mb-4">{t('usage.topProjects')}</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{stats.by_project.slice(0, 3).map((project) => (
|
|
||||||
<div key={project.project_path} className="flex items-center justify-between">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium truncate max-w-[200px]" title={project.project_path}>
|
|
||||||
{project.project_path}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{project.session_count} {t('usage.sessions')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{formatCurrency(project.total_cost)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Models Tab */}
|
{/* Details Tab - Combined Models and Projects */}
|
||||||
<TabsContent value="models">
|
<TabsContent value="details" className="space-y-6 overflow-y-auto max-h-[calc(100vh-300px)]">
|
||||||
|
{/* Models Section */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{t('usage.byModel')}</h2>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{/* 饼图 */}
|
{/* 饼图 */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -664,10 +926,11 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
|
||||||
{/* Projects Tab */}
|
{/* Projects Section */}
|
||||||
<TabsContent value="projects">
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{t('usage.byProject')}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 顶部统计卡片 */}
|
{/* 顶部统计卡片 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
@@ -861,76 +1124,6 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 成本排行条形图 */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-sm font-semibold mb-4">{t('usage.projectCostRanking')}</h3>
|
|
||||||
{stats.by_project.length > 0 && (
|
|
||||||
<div className="w-full h-96 mb-6">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
data={stats.by_project.slice(0, 10).map((project) => ({
|
|
||||||
name: project.project_path.split('/').slice(-2).join('/'),
|
|
||||||
fullPath: project.project_path,
|
|
||||||
cost: project.total_cost,
|
|
||||||
sessions: project.session_count,
|
|
||||||
tokens: project.total_tokens
|
|
||||||
}))}
|
|
||||||
layout="horizontal"
|
|
||||||
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border/20" />
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
tick={{ fontSize: 10 }}
|
|
||||||
tickFormatter={(value) => formatCurrency(value)}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="name"
|
|
||||||
tick={{ fontSize: 10 }}
|
|
||||||
width={90}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<RechartsTooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: 'hsl(var(--popover))',
|
|
||||||
border: '1px solid hsl(var(--border))',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '12px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)',
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
fontSize: 11
|
|
||||||
}}
|
|
||||||
labelStyle={{
|
|
||||||
color: 'hsl(var(--popover-foreground))',
|
|
||||||
fontWeight: 600
|
|
||||||
}}
|
|
||||||
itemStyle={{
|
|
||||||
color: 'hsl(var(--popover-foreground))'
|
|
||||||
}}
|
|
||||||
formatter={(value: number, name: string, props: any) => {
|
|
||||||
if (name === 'cost') {
|
|
||||||
return [
|
|
||||||
formatCurrency(value),
|
|
||||||
`${props.payload.sessions} ${t('usage.sessions')}, ${formatTokens(props.payload.tokens)} tokens`
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [value, name];
|
|
||||||
}}
|
|
||||||
labelFormatter={(label) => `${t('usage.project')}: ${label}`}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="cost"
|
|
||||||
fill="#3b82f6"
|
|
||||||
radius={[0, 4, 4, 0]}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 详细列表 */}
|
{/* 详细列表 */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-sm font-semibold mb-4">{t('usage.projectDetails')}</h3>
|
<h3 className="text-sm font-semibold mb-4">{t('usage.projectDetails')}</h3>
|
||||||
@@ -961,130 +1154,7 @@ export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
|
||||||
{/* Sessions Tab */}
|
|
||||||
<TabsContent value="sessions">
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-sm font-semibold mb-4">{t('usage.usageBySession')}</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sessionStats?.map((session) => (
|
|
||||||
<div key={`${session.project_path}-${session.project_name}`} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-xs font-mono text-muted-foreground truncate max-w-[200px]" title={session.project_path}>
|
|
||||||
{session.project_path.split('/').slice(-2).join('/')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium mt-1">
|
|
||||||
{session.project_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-semibold">{formatCurrency(session.total_cost)}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{new Date(session.last_used).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Timeline Tab */}
|
|
||||||
<TabsContent value="timeline">
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-sm font-semibold mb-6 flex items-center space-x-2">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
<span>{t('usage.dailyUsage')}</span>
|
|
||||||
</h3>
|
|
||||||
{stats.by_date.length > 0 ? (() => {
|
|
||||||
// 准备图表数据
|
|
||||||
const chartData = stats.by_date.slice().reverse().map((day) => {
|
|
||||||
const date = new Date(day.date.replace(/-/g, '/'));
|
|
||||||
return {
|
|
||||||
date: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
|
||||||
fullDate: date.toLocaleDateString(undefined, {
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
}),
|
|
||||||
cost: day.total_cost,
|
|
||||||
tokens: day.total_tokens,
|
|
||||||
models: day.models_used.length
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 自定义Tooltip
|
|
||||||
const CustomTooltip = ({ active, payload }: any) => {
|
|
||||||
if (active && payload && payload[0]) {
|
|
||||||
const data = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="bg-background border border-border rounded-lg shadow-lg p-3">
|
|
||||||
<p className="text-sm font-semibold">{data.fullDate}</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{t('usage.cost')}: {formatCurrency(data.cost)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatTokens(data.tokens)} {t('usage.tokens')}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{data.models} {t('usage.models')}{data.models !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart
|
|
||||||
data={chartData}
|
|
||||||
margin={{ top: 10, right: 30, left: 0, bottom: 40 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="colorCost" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#d97757" stopOpacity={0.8}/>
|
|
||||||
<stop offset="95%" stopColor="#d97757" stopOpacity={0.1}/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
tickFormatter={(value) => formatCurrency(value)}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<RechartsTooltip content={<CustomTooltip />} />
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="cost"
|
|
||||||
stroke="#d97757"
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="url(#colorCost)"
|
|
||||||
animationDuration={1000}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})() : (
|
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
||||||
{t('usage.noUsageData')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@@ -57,7 +57,6 @@
|
|||||||
"claudeCodeSession": "Claude Code Session",
|
"claudeCodeSession": "Claude Code Session",
|
||||||
"experimentalFeature": "Experimental Feature",
|
"experimentalFeature": "Experimental Feature",
|
||||||
"checkpointingWarning": "Checkpointing may affect directory structure or cause data loss. Use with caution.",
|
"checkpointingWarning": "Checkpointing may affect directory structure or cause data loss. Use with caution.",
|
||||||
"timeline": "Timeline",
|
|
||||||
"noCheckpointsYet": "No checkpoints yet",
|
"noCheckpointsYet": "No checkpoints yet",
|
||||||
"sessionTimeline": "Session Timeline",
|
"sessionTimeline": "Session Timeline",
|
||||||
"checkpoints": "checkpoints",
|
"checkpoints": "checkpoints",
|
||||||
@@ -561,11 +560,14 @@
|
|||||||
"totalCost": "Total Cost",
|
"totalCost": "Total Cost",
|
||||||
"byModel": "By Model",
|
"byModel": "By Model",
|
||||||
"byProject": "By Project",
|
"byProject": "By Project",
|
||||||
"byDate": "By Date",
|
"last24Hours": "Last 24 Hours",
|
||||||
"last7Days": "Last 7 Days",
|
"last7Days": "Last 7 Days",
|
||||||
"last30Days": "Last 30 Days",
|
"last30Days": "Last 30 Days",
|
||||||
"allTime": "All Time",
|
"allTime": "All Time",
|
||||||
"exportData": "Export Data",
|
"exportData": "Export Data",
|
||||||
|
"todayRequests": "Today's Requests",
|
||||||
|
"todayCost": "Today's Cost",
|
||||||
|
"todayTokens": "Today's Tokens",
|
||||||
"usageDashboardTitle": "Usage Dashboard",
|
"usageDashboardTitle": "Usage Dashboard",
|
||||||
"trackUsageAndCosts": "Track your Claude Code usage and costs",
|
"trackUsageAndCosts": "Track your Claude Code usage and costs",
|
||||||
"allTime": "All Time",
|
"allTime": "All Time",
|
||||||
@@ -574,6 +576,7 @@
|
|||||||
"totalTokens": "Total Tokens",
|
"totalTokens": "Total Tokens",
|
||||||
"avgCostPerSession": "Avg Cost/Session",
|
"avgCostPerSession": "Avg Cost/Session",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
|
"details": "Details",
|
||||||
"tokenBreakdown": "Token Breakdown",
|
"tokenBreakdown": "Token Breakdown",
|
||||||
"inputTokens": "Input Tokens",
|
"inputTokens": "Input Tokens",
|
||||||
"outputTokens": "Output Tokens",
|
"outputTokens": "Output Tokens",
|
||||||
@@ -585,7 +588,6 @@
|
|||||||
"usageByModel": "Usage by Model",
|
"usageByModel": "Usage by Model",
|
||||||
"usageByProject": "Usage by Project",
|
"usageByProject": "Usage by Project",
|
||||||
"usageBySession": "Usage by Session",
|
"usageBySession": "Usage by Session",
|
||||||
"timeline": "Timeline",
|
|
||||||
"dailyUsage": "Daily Usage",
|
"dailyUsage": "Daily Usage",
|
||||||
"tokens": "tokens",
|
"tokens": "tokens",
|
||||||
"models": "models",
|
"models": "models",
|
||||||
@@ -596,13 +598,14 @@
|
|||||||
"failedToLoadUsageStats": "Failed to load usage statistics. Please try again.",
|
"failedToLoadUsageStats": "Failed to load usage statistics. Please try again.",
|
||||||
"tryAgain": "Try Again",
|
"tryAgain": "Try Again",
|
||||||
"dailyUsageOverTime": "Daily Usage Over Time",
|
"dailyUsageOverTime": "Daily Usage Over Time",
|
||||||
|
"hourlyUsageToday": "24-Hour Usage Pattern",
|
||||||
|
"last24HoursPattern": "Last 24 Hours Usage Pattern",
|
||||||
"noUsageData": "No usage data available for the selected period",
|
"noUsageData": "No usage data available for the selected period",
|
||||||
"totalProjects": "Total Projects",
|
"totalProjects": "Total Projects",
|
||||||
"avgProjectCost": "Average Project Cost",
|
"avgProjectCost": "Average Project Cost",
|
||||||
"topProjectCost": "Highest Project Cost",
|
"topProjectCost": "Highest Project Cost",
|
||||||
"projectCostDistribution": "Project Cost Distribution",
|
"projectCostDistribution": "Project Cost Distribution",
|
||||||
"projectTokenUsage": "Project Token Usage",
|
"projectTokenUsage": "Project Token Usage",
|
||||||
"projectCostRanking": "Project Cost Ranking",
|
|
||||||
"projectDetails": "Project Details",
|
"projectDetails": "Project Details",
|
||||||
"noProjectData": "No project data available",
|
"noProjectData": "No project data available",
|
||||||
"project": "Project",
|
"project": "Project",
|
||||||
|
@@ -54,7 +54,6 @@
|
|||||||
"claudeCodeSession": "Claude Code 会话",
|
"claudeCodeSession": "Claude Code 会话",
|
||||||
"experimentalFeature": "实验性功能",
|
"experimentalFeature": "实验性功能",
|
||||||
"checkpointingWarning": "检查点可能会影响目录结构或导致数据丢失。请谨慎使用。",
|
"checkpointingWarning": "检查点可能会影响目录结构或导致数据丢失。请谨慎使用。",
|
||||||
"timeline": "时间线",
|
|
||||||
"noCheckpointsYet": "尚无检查点",
|
"noCheckpointsYet": "尚无检查点",
|
||||||
"sessionTimeline": "会话时间线",
|
"sessionTimeline": "会话时间线",
|
||||||
"checkpoints": "个检查点",
|
"checkpoints": "个检查点",
|
||||||
@@ -542,11 +541,14 @@
|
|||||||
"totalCost": "总成本",
|
"totalCost": "总成本",
|
||||||
"byModel": "按模型",
|
"byModel": "按模型",
|
||||||
"byProject": "按项目",
|
"byProject": "按项目",
|
||||||
"byDate": "按日期",
|
"last24Hours": "最近24小时",
|
||||||
"last7Days": "最近 7 天",
|
"last7Days": "最近 7 天",
|
||||||
"last30Days": "最近 30 天",
|
"last30Days": "最近 30 天",
|
||||||
"allTime": "全部时间",
|
"allTime": "全部时间",
|
||||||
"exportData": "导出数据",
|
"exportData": "导出数据",
|
||||||
|
"todayRequests": "今日请求",
|
||||||
|
"todayCost": "今日成本",
|
||||||
|
"todayTokens": "今日令牌",
|
||||||
"usageDashboardTitle": "用量仪表板",
|
"usageDashboardTitle": "用量仪表板",
|
||||||
"trackUsageAndCosts": "跟踪您的 Claude Code 用量和成本",
|
"trackUsageAndCosts": "跟踪您的 Claude Code 用量和成本",
|
||||||
"allTime": "全部时间",
|
"allTime": "全部时间",
|
||||||
@@ -555,6 +557,7 @@
|
|||||||
"totalTokens": "总令牌数",
|
"totalTokens": "总令牌数",
|
||||||
"avgCostPerSession": "平均每会话成本",
|
"avgCostPerSession": "平均每会话成本",
|
||||||
"overview": "概览",
|
"overview": "概览",
|
||||||
|
"details": "详情",
|
||||||
"tokenBreakdown": "令牌明细",
|
"tokenBreakdown": "令牌明细",
|
||||||
"inputTokens": "输入令牌",
|
"inputTokens": "输入令牌",
|
||||||
"outputTokens": "输出令牌",
|
"outputTokens": "输出令牌",
|
||||||
@@ -566,7 +569,6 @@
|
|||||||
"usageByModel": "按模型统计用量",
|
"usageByModel": "按模型统计用量",
|
||||||
"usageByProject": "按项目统计用量",
|
"usageByProject": "按项目统计用量",
|
||||||
"usageBySession": "按会话统计用量",
|
"usageBySession": "按会话统计用量",
|
||||||
"timeline": "时间线",
|
|
||||||
"dailyUsage": "日常用量",
|
"dailyUsage": "日常用量",
|
||||||
"tokens": "令牌",
|
"tokens": "令牌",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
@@ -577,13 +579,14 @@
|
|||||||
"failedToLoadUsageStats": "加载用量统计失败。请重试。",
|
"failedToLoadUsageStats": "加载用量统计失败。请重试。",
|
||||||
"tryAgain": "重试",
|
"tryAgain": "重试",
|
||||||
"dailyUsageOverTime": "随时间变化的日常用量",
|
"dailyUsageOverTime": "随时间变化的日常用量",
|
||||||
|
"hourlyUsageToday": "24小时使用模式",
|
||||||
|
"last24HoursPattern": "过去24小时使用模式",
|
||||||
"noUsageData": "选定时期内无用量数据",
|
"noUsageData": "选定时期内无用量数据",
|
||||||
"totalProjects": "项目总数",
|
"totalProjects": "项目总数",
|
||||||
"avgProjectCost": "平均项目成本",
|
"avgProjectCost": "平均项目成本",
|
||||||
"topProjectCost": "最高项目成本",
|
"topProjectCost": "最高项目成本",
|
||||||
"projectCostDistribution": "项目成本分布",
|
"projectCostDistribution": "项目成本分布",
|
||||||
"projectTokenUsage": "项目 Token 使用量",
|
"projectTokenUsage": "项目 Token 使用量",
|
||||||
"projectCostRanking": "项目成本排行",
|
|
||||||
"projectDetails": "项目详情",
|
"projectDetails": "项目详情",
|
||||||
"noProjectData": "暂无项目数据",
|
"noProjectData": "暂无项目数据",
|
||||||
"project": "项目",
|
"project": "项目",
|
||||||
|
Reference in New Issue
Block a user