Files
jiang13-bbs/frontend/components/admin/charts.tsx

253 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { ComponentProps } from "react";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
const TOOLTIP_STYLE = {
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: 12,
fontSize: 12,
color: "var(--ink)",
};
const LEGEND_STYLE = { fontSize: 12, overflow: "hidden" as const };
const LEGEND_STYLE_SM = { fontSize: 11, overflow: "hidden" as const };
/** 统一 Tooltip:关闭默认 cursor 灰框,避免选中/悬停黑边 */
function ChartTooltip(props: Omit<ComponentProps<typeof Tooltip>, "contentStyle" | "cursor">) {
return <Tooltip {...props} cursor={false} contentStyle={TOOLTIP_STYLE} />;
}
function shortDate(d: string) {
if (!d || d.length < 10) return d;
return d.slice(5);
}
export function AdminChartCard({
title,
hint,
children,
actions,
}: {
title: string;
hint?: string;
children: React.ReactNode;
actions?: React.ReactNode;
}) {
return (
<div className="panel p-4 sm:p-5 flex flex-col min-h-[260px]">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="min-w-0">
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
{title}
</h3>
{hint ? (
<p className="meta text-[11.5px] mt-0.5 leading-relaxed">{hint}</p>
) : null}
</div>
{actions}
</div>
{/* 捕获阶段拦截 mousedown:冒泡阶段太晚,浏览器已开始 SVG 多柱选区黑框 */}
<div
className="flex-1 min-h-[180px] w-full overflow-hidden select-none [&_*]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
onMouseDownCapture={(e) => e.preventDefault()}
>
{children}
</div>
</div>
);
}
export function VisitTrendChart({
data,
}: {
data: { date: string; pv: number; uv: number }[];
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(d.date) }));
return (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={rows} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
<defs>
<linearGradient id="j13Pv" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--accent)" stopOpacity={0.35} />
<stop offset="100%" stopColor="var(--accent)" stopOpacity={0} />
</linearGradient>
<linearGradient id="j13Uv" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--ok)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--ok)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
<ChartTooltip />
<Legend wrapperStyle={LEGEND_STYLE} />
<Area type="monotone" dataKey="pv" name="PV" stroke="var(--accent)" fill="url(#j13Pv)" strokeWidth={2} />
<Area type="monotone" dataKey="uv" name="UV" stroke="var(--ok)" fill="url(#j13Uv)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
);
}
export function DualLineChart({
data,
aKey,
bKey,
aName,
bName,
aColor = "var(--accent)",
bColor = "var(--gold)",
}: {
data: { date: string; [k: string]: string | number }[];
aKey: string;
bKey: string;
aName: string;
bName: string;
aColor?: string;
bColor?: string;
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
return (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={rows} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
<ChartTooltip />
<Legend wrapperStyle={LEGEND_STYLE} />
<Line type="monotone" dataKey={aKey} name={aName} stroke={aColor} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey={bKey} name={bName} stroke={bColor} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
export function SingleAreaChart({
data,
dataKey,
name,
color = "var(--accent)",
}: {
data: { date: string; [k: string]: string | number }[];
dataKey: string;
name: string;
color?: string;
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
return (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={rows} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
<ChartTooltip />
<Area type="monotone" dataKey={dataKey} name={name} stroke={color} fill={color} fillOpacity={0.15} strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
);
}
const PIE_COLORS = [
"var(--accent)",
"var(--ok)",
"var(--gold)",
"var(--clay)",
"var(--danger)",
"#8b5cf6",
"#06b6d4",
"#84cc16",
];
export function NamedBarChart({
data,
layout = "vertical",
}: {
data: { label: string; count: number }[];
layout?: "vertical" | "horizontal";
}) {
if (layout === "horizontal") {
return (
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 28)}>
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 12, left: 8, bottom: 4 }} accessibilityLayer={false}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} allowDecimals={false} />
<YAxis type="category" dataKey="label" width={72} tick={{ fill: "var(--ink-2)", fontSize: 11 }} axisLine={false} tickLine={false} />
<ChartTooltip />
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[0, 6, 6, 0]} activeBar={false} />
</BarChart>
</ResponsiveContainer>
);
}
return (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
<ChartTooltip />
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[6, 6, 0, 0]} activeBar={false} />
</BarChart>
</ResponsiveContainer>
);
}
export function NamedPieChart({ data }: { data: { label: string; count: number }[] }) {
const total = data.reduce((s, d) => s + d.count, 0) || 1;
return (
<ResponsiveContainer width="100%" height={200}>
<PieChart accessibilityLayer={false}>
<Pie data={data} dataKey="count" nameKey="label" innerRadius={48} outerRadius={72} paddingAngle={2}>
{data.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
<ChartTooltip
formatter={(value, name) => {
const n = typeof value === "number" ? value : Number(value) || 0;
return [`${n}(${Math.round((n / total) * 100)}%)`, String(name ?? "")];
}}
/>
<Legend wrapperStyle={LEGEND_STYLE_SM} />
</PieChart>
</ResponsiveContainer>
);
}
export function DeltaBarChart({
data,
}: {
data: { date: string; granted: number; spent: number }[];
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(d.date) }));
return (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={rows} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={40} allowDecimals={false} />
<ChartTooltip />
<Legend wrapperStyle={LEGEND_STYLE} />
<Bar dataKey="granted" name="发放" fill="var(--ok)" radius={[4, 4, 0, 0]} activeBar={false} />
<Bar dataKey="spent" name="消耗" fill="var(--clay)" radius={[4, 4, 0, 0]} activeBar={false} />
</BarChart>
</ResponsiveContainer>
);
}