Files
jiang13-bbs/frontend/components/admin/charts.tsx
freefire 5f7193042f feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 04:16:32 +08:00

259 lines
9.4 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,
className = "",
}: {
title: string;
hint?: string;
children: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}) {
return (
<div className={`panel p-4 sm:p-5 flex flex-col ${className}`.trim()}>
<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="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)",
height = 200,
}: {
data: { date: string; [k: string]: string | number }[];
dataKey: string;
name: string;
color?: string;
height?: number;
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
return (
<ResponsiveContainer width="100%" height={height}>
<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") {
// 固定像素高度,避免 overflow 滚动条 ↔ ResponsiveContainer 互相触发闪烁
const chartH = Math.max(180, Math.min(320, data.length * 28));
return (
<ResponsiveContainer width="100%" height={chartH}>
<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>
);
}