feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View 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>
);
}