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:
2026-08-03 11:59:27 +08:00
parent 95198e6a07
commit 65e4e6778d
6 changed files with 158 additions and 55 deletions

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View 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 (FritschCarlson) — 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>
);
}

View File

@@ -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 */}

View File

@@ -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>