fix: 修复调用趋势统计与图表渲染,柱状图改为平滑折线图
- 趋势窗口 off-by-one:窗口不含当天,当天调用永远不进图表; 改为以 UTC 日界、包含今天的 N 天窗口 - BarChart 容器 items-end 导致列高塌缩,柱子百分比高度恒为 0, 图表自组件抽取以来从未渲染;重写为 LineChart(单调三次样条 平滑曲线 + 面积填充 + 十字线 tooltip + 键盘导航) - nginx 为 index.html 增加 no-cache、/assets/ 增加 immutable 缓存,避免发版后浏览器沿用旧 bundle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,10 +8,15 @@ export type TrendRow = {
|
||||
tokens: bigint;
|
||||
};
|
||||
|
||||
/**
|
||||
* UTC midnight `days - 1` days ago, so a window of `days` buckets ends on
|
||||
* (and includes) today. UTC is used because `calledAt` is stored as naive
|
||||
* UTC and the SQL groups by its date part.
|
||||
*/
|
||||
export function daysWindowStart(days: number): Date {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
since.setHours(0, 0, 0, 0);
|
||||
const now = new Date();
|
||||
const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
since.setUTCDate(since.getUTCDate() - (days - 1));
|
||||
return since;
|
||||
}
|
||||
|
||||
@@ -43,7 +48,7 @@ export async function getDailyTrends(opts: { since: Date; days: number; projectI
|
||||
const filled = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(since);
|
||||
d.setDate(d.getDate() + i);
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const row = map.get(key);
|
||||
const total = row ? Number(row.total) : 0;
|
||||
|
||||
@@ -28,7 +28,16 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Hashed build artifacts are safe to cache forever
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
# index.html must always revalidate, or browsers keep a stale
|
||||
# bundle reference after each deploy (heuristic caching)
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type BarChartPoint = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
/** Optional secondary value rendered as an overlay (e.g., errors stacked on calls). */
|
||||
secondary?: number;
|
||||
tooltip: ReactNode;
|
||||
};
|
||||
|
||||
export default function BarChart({ points, height = 180, emptyLabel }: { points: BarChartPoint[]; height?: number; emptyLabel: string }) {
|
||||
if (points.length === 0) {
|
||||
return <div style={{ height }} className="flex items-center justify-center text-[13px] text-text-muted">{emptyLabel}</div>;
|
||||
}
|
||||
|
||||
const max = Math.max(...points.map((p) => p.value), 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1.5" style={{ height }}>
|
||||
{points.map((point) => {
|
||||
const barHeight = (point.value / max) * 100;
|
||||
const overlayHeight = point.secondary !== undefined && point.value > 0
|
||||
? (point.secondary / point.value) * barHeight
|
||||
: 0;
|
||||
return (
|
||||
<div key={point.key} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
|
||||
<div className="bg-bg-elevated border border-border-default rounded-lg shadow-lg px-3 py-2 text-[11px] whitespace-nowrap">
|
||||
{point.tooltip}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-1 flex items-end relative">
|
||||
<div className="w-full rounded-t-md bg-accent/70 group-hover:bg-accent transition-colors" style={{ height: `${Math.max(barHeight, 2)}%` }} />
|
||||
{overlayHeight > 0 && (
|
||||
<div className="w-full absolute bottom-0 rounded-t-md bg-danger/70" style={{ height: `${overlayHeight}%` }} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] text-text-muted">{point.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
packages/web/src/components/LineChart.tsx
Normal file
134
packages/web/src/components/LineChart.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
export type LineChartPoint = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
/** Optional error count; points with a value > 0 get a danger marker. */
|
||||
secondary?: number;
|
||||
tooltip: ReactNode;
|
||||
};
|
||||
|
||||
const TOP_PADDING_PCT = 8;
|
||||
|
||||
type Pt = { x: number; y: number };
|
||||
|
||||
/** Monotone cubic spline (Fritsch–Carlson) — smooth but never overshoots the data. */
|
||||
function smoothPath(pts: Pt[]): string {
|
||||
if (pts.length < 2) return pts.length ? `M ${pts[0].x} ${pts[0].y}` : '';
|
||||
const n = pts.length;
|
||||
const dx: number[] = [];
|
||||
const slope: number[] = [];
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
dx.push(pts[i + 1].x - pts[i].x);
|
||||
slope.push((pts[i + 1].y - pts[i].y) / dx[i]);
|
||||
}
|
||||
const m: number[] = [slope[0]];
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (slope[i - 1] * slope[i] <= 0) {
|
||||
m.push(0);
|
||||
} else {
|
||||
const w1 = 2 * dx[i] + dx[i - 1];
|
||||
const w2 = dx[i] + 2 * dx[i - 1];
|
||||
m.push((w1 + w2) / (w1 / slope[i - 1] + w2 / slope[i]));
|
||||
}
|
||||
}
|
||||
m.push(slope[n - 2]);
|
||||
|
||||
const f = (v: number) => +v.toFixed(2);
|
||||
let d = `M ${f(pts[0].x)} ${f(pts[0].y)}`;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const t = dx[i] / 3;
|
||||
d += ` C ${f(pts[i].x + t)} ${f(pts[i].y + m[i] * t)}, ${f(pts[i + 1].x - t)} ${f(pts[i + 1].y - m[i + 1] * t)}, ${f(pts[i + 1].x)} ${f(pts[i + 1].y)}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export default function LineChart({ points, height = 180, emptyLabel }: { points: LineChartPoint[]; height?: number; emptyLabel: string }) {
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
|
||||
if (points.length === 0) {
|
||||
return <div style={{ height }} className="flex items-center justify-center text-[13px] text-text-muted">{emptyLabel}</div>;
|
||||
}
|
||||
|
||||
const n = points.length;
|
||||
const max = Math.max(...points.map((p) => p.value), 1);
|
||||
const x = (i: number) => (n === 1 ? 50 : (i / (n - 1)) * 100);
|
||||
const y = (v: number) => 100 - (v / max) * (100 - TOP_PADDING_PCT);
|
||||
|
||||
const linePath = smoothPath(points.map((p, i) => ({ x: x(i), y: y(p.value) })));
|
||||
const areaPath = `${linePath} L ${x(n - 1)} 100 L ${x(0)} 100 Z`;
|
||||
const labelStep = Math.max(1, Math.ceil(n / 8));
|
||||
|
||||
const snapTo = (clientX: number, el: HTMLElement) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ratio = (clientX - rect.left) / rect.width;
|
||||
setActive(Math.min(n - 1, Math.max(0, Math.round(ratio * (n - 1)))));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height }} className="flex flex-col">
|
||||
<div
|
||||
className="relative flex-1 border-b border-border-muted outline-none cursor-crosshair"
|
||||
tabIndex={0}
|
||||
onPointerMove={(e) => snapTo(e.clientX, e.currentTarget)}
|
||||
onPointerLeave={() => setActive(null)}
|
||||
onFocus={() => setActive((a) => a ?? n - 1)}
|
||||
onBlur={() => setActive(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); setActive((a) => Math.max(0, (a ?? n) - 1)); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); setActive((a) => Math.min(n - 1, (a ?? -1) + 1)); }
|
||||
}}
|
||||
>
|
||||
<svg className="absolute inset-0 w-full h-full text-accent" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d={areaPath} fill="currentColor" fillOpacity="0.1" />
|
||||
<path d={linePath} fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
|
||||
{/* Persistent markers: series endpoint + days with errors */}
|
||||
{points.map((p, i) => {
|
||||
const hasError = (p.secondary ?? 0) > 0;
|
||||
if (!hasError && i !== n - 1) return null;
|
||||
return (
|
||||
<div
|
||||
key={p.key}
|
||||
className={`absolute w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 ring-2 ring-bg-elevated pointer-events-none ${hasError ? 'bg-danger' : 'bg-accent'}`}
|
||||
style={{ left: `${x(i)}%`, top: `${y(p.value)}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{active !== null && (
|
||||
<>
|
||||
<div className="absolute inset-y-0 w-px bg-border-default pointer-events-none" style={{ left: `${x(active)}%` }} />
|
||||
<div
|
||||
className="absolute w-2.5 h-2.5 rounded-full bg-accent ring-2 ring-bg-elevated -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{ left: `${x(active)}%`, top: `${y(points[active].value)}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute z-10 -translate-x-1/2 -translate-y-full pointer-events-none"
|
||||
style={{ left: `${Math.min(82, Math.max(18, x(active)))}%`, top: `${Math.max(y(points[active].value) - 6, 0)}%` }}
|
||||
>
|
||||
<div className="bg-bg-elevated border border-border-default rounded-lg shadow-lg px-3 py-2 text-[11px] whitespace-nowrap">
|
||||
{points[active].tooltip}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* X-axis labels: sparse to avoid collisions on long ranges */}
|
||||
<div className="relative h-4 mt-1.5">
|
||||
{points.map((p, i) => {
|
||||
if (i % labelStep !== 0) return null;
|
||||
const align = i === 0 ? 'translate-x-0' : i === n - 1 ? '-translate-x-full' : '-translate-x-1/2';
|
||||
return (
|
||||
<span key={p.key} className={`absolute whitespace-nowrap text-[9px] text-text-muted ${align}`} style={{ left: `${x(i)}%` }}>
|
||||
{p.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import BarChart, { type BarChartPoint } from '../../components/BarChart';
|
||||
import LineChart, { type LineChartPoint } from '../../components/LineChart';
|
||||
|
||||
type Stats = {
|
||||
totalUsers: number;
|
||||
@@ -138,7 +138,7 @@ export default function Dashboard() {
|
||||
},
|
||||
];
|
||||
|
||||
const trendPoints: BarChartPoint[] = (trends ?? []).map((point) => ({
|
||||
const trendPoints: LineChartPoint[] = (trends ?? []).map((point) => ({
|
||||
key: point.date,
|
||||
label: point.date.slice(5),
|
||||
value: point.calls,
|
||||
@@ -176,7 +176,7 @@ export default function Dashboard() {
|
||||
{/* Trend Chart */}
|
||||
<div className="xl:col-span-3 card p-5">
|
||||
<h3 className="section-title mb-4">7 天调用趋势</h3>
|
||||
<BarChart points={trendPoints} emptyLabel="暂无调用数据" />
|
||||
<LineChart points={trendPoints} emptyLabel="暂无调用数据" />
|
||||
</div>
|
||||
|
||||
{/* Recent Calls */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import BarChart, { type BarChartPoint } from '../../components/BarChart';
|
||||
import LineChart, { type LineChartPoint } from '../../components/LineChart';
|
||||
|
||||
type DailyPoint = { date: string; calls: number; errors: number; tokens: number };
|
||||
type ToolStat = { toolName: string; calls: number; tokens: number };
|
||||
@@ -24,7 +24,7 @@ export default function Usage({ projectId }: { projectId: string }) {
|
||||
|
||||
const maxToolCalls = Math.max(...(data?.byTool.map((b) => b.calls) ?? [1]), 1);
|
||||
|
||||
const dailyPoints: BarChartPoint[] = (data?.daily ?? []).map((point) => ({
|
||||
const dailyPoints: LineChartPoint[] = (data?.daily ?? []).map((point) => ({
|
||||
key: point.date,
|
||||
label: point.date.slice(5),
|
||||
value: point.calls,
|
||||
@@ -78,7 +78,7 @@ export default function Usage({ projectId }: { projectId: string }) {
|
||||
{isLoading ? (
|
||||
<div className="h-[180px] skeleton" />
|
||||
) : (
|
||||
<BarChart points={dailyPoints} emptyLabel={t('dashboard.usage.empty')} />
|
||||
<LineChart points={dailyPoints} emptyLabel={t('dashboard.usage.empty')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user