From 65e4e6778d78fde8237b3f712939f870c7d43756 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Mon, 3 Aug 2026 11:59:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E7=BB=9F=E8=AE=A1=E4=B8=8E=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E6=B8=B2=E6=9F=93,=E6=9F=B1=E7=8A=B6=E5=9B=BE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=B9=B3=E6=BB=91=E6=8A=98=E7=BA=BF=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 趋势窗口 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 --- packages/server/src/lib/trends.ts | 13 +- packages/web/nginx.conf | 9 ++ packages/web/src/components/BarChart.tsx | 45 ------- packages/web/src/components/LineChart.tsx | 134 +++++++++++++++++++++ packages/web/src/pages/admin/Dashboard.tsx | 6 +- packages/web/src/pages/tabs/Usage.tsx | 6 +- 6 files changed, 158 insertions(+), 55 deletions(-) delete mode 100644 packages/web/src/components/BarChart.tsx create mode 100644 packages/web/src/components/LineChart.tsx diff --git a/packages/server/src/lib/trends.ts b/packages/server/src/lib/trends.ts index ec905bb..f7858e8 100644 --- a/packages/server/src/lib/trends.ts +++ b/packages/server/src/lib/trends.ts @@ -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; diff --git a/packages/web/nginx.conf b/packages/web/nginx.conf index 53f5f28..1758628 100644 --- a/packages/web/nginx.conf +++ b/packages/web/nginx.conf @@ -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; } } diff --git a/packages/web/src/components/BarChart.tsx b/packages/web/src/components/BarChart.tsx deleted file mode 100644 index 246172b..0000000 --- a/packages/web/src/components/BarChart.tsx +++ /dev/null @@ -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
{emptyLabel}
; - } - - const max = Math.max(...points.map((p) => p.value), 1); - - return ( -
- {points.map((point) => { - const barHeight = (point.value / max) * 100; - const overlayHeight = point.secondary !== undefined && point.value > 0 - ? (point.secondary / point.value) * barHeight - : 0; - return ( -
-
-
- {point.tooltip} -
-
-
-
- {overlayHeight > 0 && ( -
- )} -
- {point.label} -
- ); - })} -
- ); -} diff --git a/packages/web/src/components/LineChart.tsx b/packages/web/src/components/LineChart.tsx new file mode 100644 index 0000000..be4a89c --- /dev/null +++ b/packages/web/src/components/LineChart.tsx @@ -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(null); + + if (points.length === 0) { + return
{emptyLabel}
; + } + + 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 ( +
+
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)); } + }} + > + + + {/* 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 ( +
+ ); + })} + + {active !== null && ( + <> +
+
+
+
+ {points[active].tooltip} +
+
+ + )} +
+ + {/* X-axis labels: sparse to avoid collisions on long ranges */} +
+ {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 ( + + {p.label} + + ); + })} +
+
+ ); +} diff --git a/packages/web/src/pages/admin/Dashboard.tsx b/packages/web/src/pages/admin/Dashboard.tsx index 0911036..b9673fd 100644 --- a/packages/web/src/pages/admin/Dashboard.tsx +++ b/packages/web/src/pages/admin/Dashboard.tsx @@ -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 */}

7 天调用趋势

- +
{/* Recent Calls */} diff --git a/packages/web/src/pages/tabs/Usage.tsx b/packages/web/src/pages/tabs/Usage.tsx index 41c3bae..1cfd2f8 100644 --- a/packages/web/src/pages/tabs/Usage.tsx +++ b/packages/web/src/pages/tabs/Usage.tsx @@ -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 ? (
) : ( - + )}