Files
UHD_Media_Tool/apps/web/app/components/sites/user-data-chart.tsx
T
rootandClaude Opus 5.5 fb84a71f80 style(copy): fix remaining punctuation violations and make check:copy strict
Auto-fix of 602 string literals across 74 files (half-width punctuation next to
Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow
the new full-width summaries. check:copy now runs with --strict in ci.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:34:51 +08:00

200 lines
7.5 KiB
TypeScript

import type { SiteUserDataPoint } from "@umt/contracts";
import { useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Button } from "~/components/ui/button.tsx";
import { formatBytes } from "~/lib/utils.ts";
import { ratioText } from "./site-fields.ts";
// 用户数据 30 天折线。主色无彩(interface-materials §1):系列靠明暗 + 实线 / 虚线区分,不用色相;
// 字节与分享率量纲不同,分两张图,不做双轴。
const INK = "var(--foreground)";
const INK_2 = "var(--muted-foreground)";
const GRID = "var(--border)";
type Row = { day: string; upload: number; download: number; ratio: number | null };
const dayLabel = (d: string) => d.slice(5).replace("-", "/");
function Legend({ items }: { items: { label: string; dashed?: boolean; muted?: boolean }[] }) {
return (
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
{items.map((i) => (
<li key={i.label} className="flex items-center gap-1.5">
<svg width="18" height="6" aria-hidden className="shrink-0">
<line
x1="1"
y1="3"
x2="17"
y2="3"
stroke={i.muted ? INK_2 : INK}
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={i.dashed ? "4 3" : undefined}
/>
</svg>
{i.label}
</li>
))}
</ul>
);
}
function TipBox({
active,
payload,
label,
kind,
}: {
active?: boolean;
payload?: { payload: Row }[];
label?: string;
kind: "bytes" | "ratio";
}) {
const row = payload?.[0]?.payload;
if (!active || !row) return null;
return (
<div className="pg-float rounded-lg px-3 py-2 text-meta">
<p className="mb-1 font-medium text-foreground">{label}</p>
{kind === "bytes" ? (
<>
<p className="text-muted-foreground">
上传 <span className="text-foreground tabular-nums">{formatBytes(row.upload)}</span>
</p>
<p className="text-muted-foreground">
下载 <span className="text-foreground tabular-nums">{formatBytes(row.download)}</span>
</p>
</>
) : (
<p className="text-muted-foreground">
分享率 <span className="text-foreground tabular-nums">{ratioText(row)}</span>
</p>
)}
</div>
);
}
const axisProps = {
stroke: GRID,
tick: { fill: "var(--muted-foreground)", fontSize: 11 },
tickLine: false,
axisLine: false,
} as const;
export function UserDataChart({ points }: { points: SiteUserDataPoint[] }) {
const [table, setTable] = useState(false);
const rows: Row[] = [...points]
.sort((a, b) => a.day.localeCompare(b.day))
.map((p) => ({ day: dayLabel(p.day), upload: p.upload, download: p.download, ratio: p.ratio }));
if (rows.length < 2)
return (
<p className="py-6 text-center text-caption text-muted-foreground">
{rows.length ? "只有 1 天的数据,满 2 天后显示趋势" : "还没有历史数据。刷新登录成功后每天记一次"}
</p>
);
return (
<div className="space-y-5">
<figure className="min-w-0">
<figcaption className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-caption font-medium text-foreground">上传 / 下载</span>
<Legend items={[{ label: "上传" }, { label: "下载", dashed: true, muted: true }]} />
</figcaption>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="0" vertical={false} />
<XAxis dataKey="day" {...axisProps} minTickGap={24} />
<YAxis {...axisProps} width={64} tickFormatter={(v: number) => formatBytes(v, 0)} />
<Tooltip
cursor={{ stroke: INK_2, strokeWidth: 1 }}
content={(p) => <TipBox {...(p as object)} kind="bytes" />}
/>
<Line
type="monotone"
dataKey="upload"
name="上传"
stroke={INK}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK }}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="download"
name="下载"
stroke={INK_2}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK_2 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</figure>
<figure className="min-w-0">
<figcaption className="mb-2 text-caption font-medium text-foreground">分享率</figcaption>
<div className="h-32 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} vertical={false} />
<XAxis dataKey="day" {...axisProps} minTickGap={24} />
<YAxis {...axisProps} width={64} domain={["auto", "auto"]} tickFormatter={(v: number) => v.toFixed(1)} />
<Tooltip
cursor={{ stroke: INK_2, strokeWidth: 1 }}
content={(p) => <TipBox {...(p as object)} kind="ratio" />}
/>
<Line
type="monotone"
dataKey="ratio"
name="分享率"
stroke={INK}
strokeWidth={2}
dot={false}
connectNulls
activeDot={{ r: 4, stroke: "var(--background)", strokeWidth: 2, fill: INK }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</figure>
<div>
<Button variant="ghost" size="xs" onClick={() => setTable((t) => !t)} aria-expanded={table}>
{table ? "收起数据表" : "查看数据表"}
</Button>
{table ? (
<div className="mt-2 max-h-64 overflow-auto rounded-lg border border-border/60">
<table className="w-full text-meta tabular-nums">
<thead className="sticky top-0 bg-background text-muted-foreground">
<tr>
<th className="px-3 py-1.5 text-left font-medium">日期</th>
<th className="px-3 py-1.5 text-right font-medium">上传</th>
<th className="px-3 py-1.5 text-right font-medium">下载</th>
<th className="px-3 py-1.5 text-right font-medium">分享率</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60 text-foreground">
{[...rows].reverse().map((r) => (
<tr key={r.day}>
<td className="px-3 py-1.5">{r.day}</td>
<td className="px-3 py-1.5 text-right">{formatBytes(r.upload)}</td>
<td className="px-3 py-1.5 text-right">{formatBytes(r.download)}</td>
<td className="px-3 py-1.5 text-right">{ratioText(r)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</div>
);
}