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]>
200 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|