feat: API analytics dashboard with i18n and theme support
Next.js full-stack analytics dashboard for new-api. - Direct PostgreSQL readonly queries on logs table - 5 pages: Dashboard, Rankings, Aggregation, Logs, Detail - Dark/Light/System theme with CSS variables - Chinese/English i18n (default Chinese) - Recharts with dual Y-axis for input/output tokens - Lucide icons + Motion animations - Docker + docker-compose with external sinobridge network, port 8019
This commit is contained in:
17
components/ClientProviders.tsx
Normal file
17
components/ClientProviders.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { I18nProvider } from "@/lib/i18n";
|
||||
import { ThemeProvider } from "@/lib/theme";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
|
||||
export function ClientProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<Sidebar />
|
||||
<main className="ml-[220px] min-h-screen p-6 lg:p-8">{children}</main>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
114
components/Sidebar.tsx
Normal file
114
components/Sidebar.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion } from "motion/react";
|
||||
import { LayoutDashboard, Trophy, ScrollText, Users, Activity, Sun, Moon, Monitor, Languages } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useTheme, type Theme } from "@/lib/theme";
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const nav = [
|
||||
{ href: "/", label: t("nav.overview"), icon: LayoutDashboard },
|
||||
{ href: "/rankings", label: t("nav.rankings"), icon: Trophy },
|
||||
{ href: "/aggregation", label: t("nav.aggregation"), icon: Users },
|
||||
{ href: "/logs", label: t("nav.logs"), icon: ScrollText },
|
||||
];
|
||||
|
||||
const themes: { value: Theme; icon: typeof Sun; label: string }[] = [
|
||||
{ value: "light", icon: Sun, label: t("theme.light") },
|
||||
{ value: "dark", icon: Moon, label: t("theme.dark") },
|
||||
{ value: "system", icon: Monitor, label: t("theme.system") },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-30 flex h-screen w-[220px] flex-col glass !rounded-none !border-l-0 !border-t-0 !border-b-0">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-t px-5" style={{ borderColor: "var(--surface-border)" }}>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg border" style={{ borderColor: "var(--surface-border)", background: "var(--btn-active-bg)" }}>
|
||||
<Activity className="h-4 w-4 text-t-accent" style={{ color: "var(--accent)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold tracking-wide text-t-primary" style={{ color: "var(--text-primary)" }}>Neural</span>
|
||||
<span className="text-sm font-light tracking-wide" style={{ color: "var(--accent)" }}>Pulse</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 p-3 pt-4">
|
||||
{nav.map((item) => {
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<motion.div
|
||||
className="relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors"
|
||||
style={{ color: active ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||
whileHover={{ x: 2 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId="sidebar-active"
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{ background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
|
||||
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative z-10 h-4 w-4" />
|
||||
<span className="relative z-10 font-medium">{item.label}</span>
|
||||
{active && (
|
||||
<motion.div
|
||||
className="absolute left-0 top-1/2 h-5 w-[2px] -translate-y-1/2 rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
layoutId="sidebar-indicator"
|
||||
transition={{ type: "spring", stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="space-y-3 p-4 border-t" style={{ borderColor: "var(--surface-border)" }}>
|
||||
{/* Theme switcher */}
|
||||
<div className="flex gap-1 rounded-lg p-0.5" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||
{themes.map(({ value, icon: Icon }) => (
|
||||
<button key={value} onClick={() => setTheme(value)}
|
||||
className="flex-1 flex items-center justify-center rounded-md py-1.5 transition-colors"
|
||||
style={{
|
||||
background: theme === value ? "var(--btn-active-bg)" : "transparent",
|
||||
color: theme === value ? "var(--text-accent)" : "var(--text-muted)",
|
||||
border: theme === value ? "1px solid var(--surface-border)" : "1px solid transparent",
|
||||
}}
|
||||
title={themes.find(t => t.value === value)?.label}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Language switcher */}
|
||||
<button
|
||||
onClick={() => setLocale(locale === "zh" ? "en" : "zh")}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-xs transition-colors"
|
||||
style={{ color: "var(--text-muted)", background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}
|
||||
>
|
||||
<Languages className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{locale === "zh" ? "English" : "中文"}</span>
|
||||
</button>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.5)]" />
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{t("common.systemOnline")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
39
components/StatsCard.tsx
Normal file
39
components/StatsCard.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { formatNumber, formatTokens } from "@/lib/utils";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
format?: "number" | "tokens";
|
||||
icon: LucideIcon;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function StatsCard({ title, value, format = "number", icon: Icon, delay = 0 }: StatsCardProps) {
|
||||
const display = format === "tokens" ? formatTokens(value) : formatNumber(value);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="glass group p-5 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{title}</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-tight font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>
|
||||
{display}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors"
|
||||
style={{ background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}>
|
||||
<Icon className="h-4 w-4" style={{ color: "var(--accent)", opacity: 0.7 }} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
41
components/TimeRangeSelector.tsx
Normal file
41
components/TimeRangeSelector.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { type TimeRange } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export function TimeRangeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: TimeRange;
|
||||
onChange: (v: TimeRange) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const ranges: { label: string; value: TimeRange }[] = [
|
||||
{ label: t("time.today"), value: "today" },
|
||||
{ label: t("time.7d"), value: "7d" },
|
||||
{ label: t("time.30d"), value: "30d" },
|
||||
{ label: t("time.all"), value: "all" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg p-1" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
|
||||
{ranges.map((r) => (
|
||||
<button key={r.value} onClick={() => onChange(r.value)}
|
||||
className="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
style={{ color: value === r.value ? "var(--text-accent)" : "var(--text-muted)" }}
|
||||
>
|
||||
{value === r.value && (
|
||||
<motion.div layoutId="time-range-bg"
|
||||
className="absolute inset-0 rounded-md"
|
||||
style={{ background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{r.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/charts/RankingBar.tsx
Normal file
68
components/charts/RankingBar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { formatTokens } from "@/lib/utils";
|
||||
|
||||
interface RankItem {
|
||||
name: string;
|
||||
total_tokens: number;
|
||||
calls: number;
|
||||
}
|
||||
|
||||
const BAR_COLORS = [
|
||||
"#00e5ff", "#00bcd4", "#0097a7", "#7c4dff",
|
||||
"#651fff", "#536dfe", "#448aff", "#40c4ff",
|
||||
"#18ffff", "#84ffff",
|
||||
];
|
||||
|
||||
export function RankingBar({
|
||||
data,
|
||||
title,
|
||||
}: {
|
||||
data: RankItem[];
|
||||
title: string;
|
||||
}) {
|
||||
if (!data.length) return null;
|
||||
const sliced = data.slice(0, 10);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={sliced}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0,229,255,0.06)" horizontal={false} />
|
||||
<XAxis type="number" tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 10, fill: "rgba(200,214,229,0.4)" }} stroke="rgba(0,229,255,0.1)" />
|
||||
<YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 11, fill: "rgba(200,214,229,0.6)" }} stroke="transparent" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "rgba(6,8,13,0.95)",
|
||||
border: "1px solid rgba(0,229,255,0.2)",
|
||||
borderRadius: "8px",
|
||||
color: "#c8d6e5",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
formatter={(v) => [formatTokens(Number(v)), "Total Tokens"]}
|
||||
/>
|
||||
<Bar dataKey="total_tokens" radius={[0, 4, 4, 0]}>
|
||||
{sliced.map((_, i) => (
|
||||
<Cell key={i} fill={BAR_COLORS[i % BAR_COLORS.length]} fillOpacity={0.7} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
components/charts/TrendChart.tsx
Normal file
113
components/charts/TrendChart.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
import { formatTokens } from "@/lib/utils";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface TrendPoint { date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number; }
|
||||
|
||||
export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint[]; metric?: "total_tokens" | "calls" }) {
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
if (!data.length)
|
||||
return <div className="flex h-64 items-center justify-center" style={{ color: "var(--text-muted)" }}>{t("common.noData")}</div>;
|
||||
|
||||
// 本地化日期格式
|
||||
const formatDateLabel = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
if (locale === "zh") {
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const tooltipStyle = {
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--surface-border)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--foreground)",
|
||||
fontSize: "12px",
|
||||
backdropFilter: "blur(16px)",
|
||||
};
|
||||
|
||||
// Token 模式:输入和输出数量级差距大,用双 Y 轴
|
||||
if (metric === "total_tokens") {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 60, left: 10, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradCyan" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="var(--accent)" />
|
||||
<stop offset="100%" stopColor="var(--accent-purple)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradPurple" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="var(--accent-purple)" />
|
||||
<stop offset="100%" stopColor="var(--accent-pink)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="date" tickFormatter={formatDateLabel} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
|
||||
{/* 左 Y 轴:输入 */}
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tickFormatter={(v) => formatTokens(v)}
|
||||
tick={{ fontSize: 10 }}
|
||||
stroke="var(--chart-grid)"
|
||||
label={{ value: t("th.input"), angle: -90, position: "insideLeft", style: { fontSize: 10, fill: "var(--text-muted)" } }}
|
||||
/>
|
||||
{/* 右 Y 轴:输出 */}
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(v) => formatTokens(v)}
|
||||
tick={{ fontSize: 10 }}
|
||||
stroke="var(--chart-grid)"
|
||||
label={{ value: t("th.output"), angle: 90, position: "insideRight", style: { fontSize: 10, fill: "var(--text-muted)" } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={(label) => {
|
||||
const d = new Date(String(label));
|
||||
return locale === "zh"
|
||||
? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||
: d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
formatTokens(Number(value)),
|
||||
name === t("th.input") ? t("th.input") : t("th.output"),
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Line yAxisId="left" type="monotone" dataKey="prompt_tokens" name={t("th.input")} stroke="url(#gradCyan)" strokeWidth={2} dot={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="completion_tokens" name={t("th.output")} stroke="url(#gradPurple)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// 调用量模式:单 Y 轴
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="date" tickFormatter={formatDateLabel} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
|
||||
<YAxis tickFormatter={(v) => formatTokens(v)} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={(label) => {
|
||||
const d = new Date(String(label));
|
||||
return locale === "zh"
|
||||
? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||
: d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
formatTokens(Number(value)),
|
||||
name === t("th.calls") ? t("th.calls") : String(name),
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="calls" name={t("th.calls")} stroke="var(--accent)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user