feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
238
frontend/components/admin/charts.tsx
Normal file
238
frontend/components/admin/charts.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
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)",
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="flex-1 min-h-[180px] w-full overflow-x-auto">{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: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<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} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<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: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<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} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<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: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<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} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<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: 4, right: 12, left: 8, bottom: 4 }}>
|
||||
<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} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[0, 6, 6, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<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} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[6, 6, 0, 0]} />
|
||||
</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>
|
||||
<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>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(value, name) => {
|
||||
const n = typeof value === "number" ? value : Number(value) || 0;
|
||||
return [`${n}(${Math.round((n / total) * 100)}%)`, String(name ?? "")];
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</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: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<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} />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="granted" name="发放" fill="var(--ok)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="spent" name="消耗" fill="var(--clay)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user