fix(admin): restore LineChart component

LineChart is used by the dashboard page to render DAU trend charts.
It was accidentally removed during metrics cleanup — restore it.
This commit is contained in:
ZhenYi 2026-04-23 15:44:10 +08:00
parent ae601774df
commit 22b5eab769

View File

@ -0,0 +1,169 @@
"use client";
import { format, parseISO } from "date-fns";
interface DataPoint {
date: string;
value: number;
}
interface LineChartProps {
data: DataPoint[];
width?: number;
height?: number;
color?: string;
label: string;
unit?: string;
}
export default function LineChart({
data,
width = 600,
height = 200,
color = "#6366f1",
label,
unit = "",
}: LineChartProps) {
if (data.length === 0) {
return (
<div style={{ color: "#737373", fontSize: "13px", textAlign: "center", padding: "32px" }}>
</div>
);
}
const padding = { top: 16, right: 16, bottom: 36, left: 44 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const values = data.map((d) => d.value);
const maxValue = Math.max(...values, 1);
const minValue = 0;
function xScale(i: number) {
return padding.left + (i / (data.length - 1 || 1)) * chartWidth;
}
function yScale(v: number) {
return padding.top + chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
}
// Build SVG path
const pathD = data
.map((d, i) => `${i === 0 ? "M" : "L"} ${xScale(i).toFixed(1)} ${yScale(d.value).toFixed(1)}`)
.join(" ");
// Area path (fill under the line)
const areaD =
pathD +
` L ${xScale(data.length - 1).toFixed(1)} ${(padding.top + chartHeight).toFixed(1)}` +
` L ${padding.left} ${(padding.top + chartHeight).toFixed(1)} Z`;
// Y-axis ticks (5 ticks)
const yTicks = Array.from({ length: 5 }, (_, i) => {
const v = minValue + ((maxValue - minValue) * i) / 4;
return { v, y: yScale(v) };
});
// X-axis ticks (show every nth label based on data length)
const step = Math.max(1, Math.floor(data.length / 6));
const xTicks = data.filter((_, i) => i % step === 0 || i === data.length - 1);
const gradientId = `grad-${label.replace(/\s/g, "-")}`;
return (
<svg
width={width}
height={height}
style={{ display: "block", maxWidth: "100%" }}
aria-label={`${label} 趋势图`}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{/* Y-axis gridlines */}
{yTicks.map((t, i) => (
<line
key={i}
x1={padding.left}
y1={t.y}
x2={width - padding.right}
y2={t.y}
stroke="#e5e5e5"
strokeWidth={1}
/>
))}
{/* Y-axis labels */}
{yTicks.map((t, i) => (
<text
key={i}
x={padding.left - 6}
y={t.y + 4}
textAnchor="end"
fontSize={11}
fill="#737373"
>
{Math.round(t.v)}
</text>
))}
{/* X-axis labels */}
{xTicks.map((d, i) => {
const idx = data.findIndex((x) => x.date === d.date);
return (
<text
key={i}
x={xScale(idx)}
y={padding.top + chartHeight + 16}
textAnchor="middle"
fontSize={11}
fill="#737373"
>
{(() => {
try {
return format(parseISO(d.date), "MM-dd");
} catch {
return d.date.slice(5);
}
})()}
</text>
);
})}
{/* Area fill */}
<path d={areaD} fill={`url(#${gradientId})`} />
{/* Line */}
<path d={pathD} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" />
{/* Data points (dots) */}
{data.map((d, i) => (
<circle
key={i}
cx={xScale(i)}
cy={yScale(d.value)}
r={3}
fill={color}
stroke="white"
strokeWidth={1.5}
style={{ cursor: "default" }}
>
<title>
{(() => {
try {
return `${format(parseISO(d.date), "yyyy-MM-dd")}: ${d.value}${unit}`;
} catch {
return `${d.date}: ${d.value}${unit}`;
}
})()}
</title>
</circle>
))}
</svg>
);
}