feat(api): add stats chart endpoint for admin access with time series data
feat(dayjs): integrate dayjs for date handling and formatting across the application refactor(routes): update date handling in id-tags, transactions, users, and dashboard routes to use dayjs style(globals): improve CSS variable definitions for better readability and consistency deps: add dayjs as a dependency for date manipulation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Card, Spinner } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
@@ -13,23 +14,29 @@ import {
|
||||
ArrowRotateRight,
|
||||
TriangleExclamation,
|
||||
} from "@gravity-ui/icons";
|
||||
import { AreaChart } from "@tremor/react";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { api, type Stats, type UserStats, type Transaction, type ChargePoint } from "@/lib/api";
|
||||
import {
|
||||
api,
|
||||
type Stats,
|
||||
type UserStats,
|
||||
type Transaction,
|
||||
type ChargePoint,
|
||||
type ChartRange,
|
||||
type ChartDataPoint,
|
||||
} from "@/lib/api";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "—";
|
||||
const ms = Date.now() - new Date(dateStr).getTime();
|
||||
if (ms < 60_000) return "刚刚";
|
||||
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} 分钟前`;
|
||||
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} 小时前`;
|
||||
return new Date(dateStr).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
||||
return dayjs(dateStr).fromNow();
|
||||
}
|
||||
|
||||
function cpOnline(cp: ChargePoint): boolean {
|
||||
if (!cp.lastHeartbeatAt) return false;
|
||||
return Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
|
||||
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||
}
|
||||
|
||||
// ── StatCard ───────────────────────────────────────────────────────────────
|
||||
@@ -98,6 +105,188 @@ function Panel({ title, children }: { title: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
// ── TrendChart ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBucket(bucket: string, range: ChartRange): string {
|
||||
const d = dayjs(bucket);
|
||||
return range === "24h" ? d.format("HH:mm") : d.format("MM/DD");
|
||||
}
|
||||
|
||||
const RANGES: { label: string; value: ChartRange }[] = [
|
||||
{ label: "24 小时", value: "24h" },
|
||||
{ label: "7 天", value: "7d" },
|
||||
{ label: "30 天", value: "30d" },
|
||||
];
|
||||
|
||||
type ChartView = "economy" | "activity";
|
||||
|
||||
const SERIES_CONFIG = {
|
||||
revenue: {
|
||||
color: "sky" as const,
|
||||
label: "营收",
|
||||
unit: "¥",
|
||||
fmt: (v: number) => (v === 0 ? "¥0" : `¥${v.toFixed(v < 10 ? 2 : 1)}`),
|
||||
},
|
||||
energyKwh: {
|
||||
color: "emerald" as const,
|
||||
label: "充电量",
|
||||
unit: "kWh",
|
||||
fmt: (v: number) => (v === 0 ? "0 kWh" : `${v.toFixed(v < 10 ? 2 : 1)} kWh`),
|
||||
},
|
||||
transactions: {
|
||||
color: "violet" as const,
|
||||
label: "充电次数",
|
||||
unit: "次",
|
||||
fmt: (v: number) => `${v} 次`,
|
||||
},
|
||||
utilizationPct: {
|
||||
color: "amber" as const,
|
||||
label: "利用率",
|
||||
unit: "%",
|
||||
fmt: (v: number) => `${v.toFixed(1)}%`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const VIEW_SERIES: Record<ChartView, (keyof typeof SERIES_CONFIG)[]> = {
|
||||
economy: ["revenue", "energyKwh"],
|
||||
activity: ["transactions", "utilizationPct"],
|
||||
};
|
||||
|
||||
function TrendChart() {
|
||||
const [range, setRange] = useState<ChartRange>("7d");
|
||||
const [view, setView] = useState<ChartView>("economy");
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (key: string) =>
|
||||
setHidden((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const switchView = (v: ChartView) => {
|
||||
setView(v);
|
||||
setHidden(new Set());
|
||||
};
|
||||
|
||||
const allSeriesForView = VIEW_SERIES[view];
|
||||
const visibleSeries = allSeriesForView.filter((k) => !hidden.has(k));
|
||||
const visibleColors = visibleSeries.map((k) => SERIES_CONFIG[k].color);
|
||||
|
||||
const { data, isPending } = useQuery<ChartDataPoint[]>({
|
||||
queryKey: ["stats-chart", range],
|
||||
queryFn: () => api.stats.chart(range),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-secondary overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-5 py-3.5">
|
||||
{/* View toggle */}
|
||||
<div className="flex gap-1">
|
||||
{(["economy", "activity"] as ChartView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => switchView(v)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
view === v
|
||||
? "bg-accent-soft text-accent"
|
||||
: "text-muted hover:bg-default hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{v === "economy" ? "营收 & 电量" : "次数 & 利用率"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Range toggle */}
|
||||
<div className="flex gap-1">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
range === r.value
|
||||
? "bg-accent text-white"
|
||||
: "text-muted hover:bg-default hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="px-4 py-4">
|
||||
{isPending ? (
|
||||
<div className="flex h-56 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<AreaChart
|
||||
data={(data ?? []).map((p) => ({ ...p, label: formatBucket(p.bucket, range) }))}
|
||||
index="label"
|
||||
categories={visibleSeries}
|
||||
colors={visibleColors}
|
||||
valueFormatter={(v) => `${v}`}
|
||||
customTooltip={(props) => {
|
||||
if (!props.active || !props.payload?.length) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-overlay px-3 py-2 shadow-md text-xs">
|
||||
<p className="mb-1.5 font-semibold text-foreground">{props.label}</p>
|
||||
{props.payload.map((entry) => {
|
||||
const key = entry.dataKey as string;
|
||||
const cfg = SERIES_CONFIG[key as keyof typeof SERIES_CONFIG];
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
|
||||
<span className="text-muted">{cfg.label}</span>
|
||||
<span className="ml-auto font-medium text-foreground">
|
||||
{cfg.fmt(entry.value as number)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
showLegend={false}
|
||||
showGridLines={true}
|
||||
showYAxis={true}
|
||||
showXAxis={true}
|
||||
curveType="monotone"
|
||||
showAnimation
|
||||
className="h-56"
|
||||
/>
|
||||
)}
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex items-center gap-2 px-1">
|
||||
{allSeriesForView.map((key) => {
|
||||
const cfg = SERIES_CONFIG[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggle(key)}
|
||||
className={`flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs transition-opacity select-none hover:opacity-100 ${
|
||||
hidden.has(key) ? "opacity-35" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
|
||||
<span className="text-muted">
|
||||
{cfg.label}({cfg.unit})
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── RecentTransactions ────────────────────────────────────────────────────
|
||||
|
||||
function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
||||
@@ -369,6 +558,9 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trend chart */}
|
||||
{isAdmin && <TrendChart />}
|
||||
|
||||
{/* Detail panels */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||
<div className="lg:col-span-2">
|
||||
|
||||
Reference in New Issue
Block a user