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:
2026-03-11 21:34:21 +08:00
parent 73f0c6243a
commit 02a361488b
27 changed files with 502 additions and 110 deletions

View File

@@ -19,6 +19,7 @@ import {
import { ArrowLeft, Pencil, PlugConnection, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
// ── Status maps ────────────────────────────────────────────────────────────
@@ -60,8 +61,7 @@ const TX_LIMIT = 10;
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const ms = new Date(stop).getTime() - new Date(start).getTime();
const min = Math.floor(ms / 60000);
const min = dayjs(stop).diff(dayjs(start), "minute");
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
@@ -69,15 +69,7 @@ function formatDuration(start: string, stop: string | null): string {
}
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
const d = Math.floor(h / 24);
return `${d} 天前`;
return dayjs(iso).fromNow();
}
// ── Edit form type ─────────────────────────────────────────────────────────
@@ -155,7 +147,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
// Online if last heartbeat within 3× interval
const isOnline =
cp?.lastHeartbeatAt != null &&
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < (cp.heartbeatInterval ?? 60) * 3 * 1000;
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
@@ -343,7 +335,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{cp.lastHeartbeatAt ? (
<span title={new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")}>
<span title={dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")}>
{relativeTime(cp.lastHeartbeatAt)}
</span>
) : (
@@ -355,7 +347,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{cp.lastBootNotificationAt ? (
<span title={new Date(cp.lastBootNotificationAt).toLocaleString("zh-CN")}>
<span title={dayjs(cp.lastBootNotificationAt).format("YYYY/M/D HH:mm:ss")}>
{relativeTime(cp.lastBootNotificationAt)}
</span>
) : (
@@ -366,7 +358,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{new Date(cp.createdAt).toLocaleDateString("zh-CN")}
{dayjs(cp.createdAt).format("YYYY/M/D")}
</dd>
</div>
</dl>
@@ -437,12 +429,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{conn.info && <p className="text-xs text-muted">{conn.info}</p>}
<p className="text-xs text-muted">
{" "}
{new Date(conn.lastStatusAt).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
</p>
</div>
))}
@@ -489,12 +476,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell className="tabular-nums text-sm">
{new Date(tx.startTimestamp).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
{dayjs(tx.startTimestamp).format("MM/DD HH:mm")}
</Table.Cell>
<Table.Cell>{formatDuration(tx.startTimestamp, tx.stopTimestamp)}</Table.Cell>
<Table.Cell className="tabular-nums">

View File

@@ -27,6 +27,7 @@ import Link from "next/link";
import { ScrollFade } from "@/components/scroll-fade";
import { api, type ChargePoint } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
const statusLabelMap: Record<string, string> = {
Available: "空闲中",
@@ -473,7 +474,7 @@ export default function ChargePointsPage() {
</Table.Cell>
<Table.Cell>
{cp.lastHeartbeatAt ? (
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
) : (
<span className="text-muted"></span>
)}

View File

@@ -13,6 +13,7 @@ import {
Xmark,
} from "@gravity-ui/icons";
import { api } from "@/lib/api";
import dayjs from "@/lib/dayjs";
// ── Status maps (same as charge-points page) ────────────────────────────────
@@ -411,7 +412,7 @@ export default function ChargePage() {
.map((cp) => {
const online =
!!cp.lastHeartbeatAt &&
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const availableCount = cp.connectors.filter(
(c) => c.status === "Available",
).length;

View File

@@ -26,6 +26,7 @@ import { parseDate } from "@internationalized/date";
import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Accepted: "success",
@@ -529,7 +530,7 @@ export default function IdTagsPage() {
)}
<Table.Cell>
{tag.expiryDate ? (
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
dayjs(tag.expiryDate).format("YYYY/M/D")
) : (
<span className="text-muted"></span>
)}
@@ -538,7 +539,7 @@ export default function IdTagsPage() {
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(tag.createdAt).toLocaleString("zh-CN")}
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
{isAdmin && (
<Table.Cell>

View File

@@ -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">

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Button, CloseButton, Input, Label, Spinner, TextField, toast } from "@heroui/react";
import { Fingerprint, Lock, Pencil, Person, TrashBin, Xmark, Check } from "@gravity-ui/icons";
import { authClient, useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
type Passkey = {
id: string;
@@ -411,11 +412,7 @@ export default function SettingsPage() {
{/* Date row: always visible */}
<p className="text-xs text-muted">
{" "}
{new Date(pk.createdAt).toLocaleString("zh-CN", {
year: "numeric",
month: "short",
day: "numeric",
})}
{dayjs(pk.createdAt).format("YYYY年M月D日")}
</p>
</div>
</div>

View File

@@ -6,13 +6,13 @@ import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
const LIMIT = 15;
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const ms = new Date(stop).getTime() - new Date(start).getTime();
const min = Math.floor(ms / 60000);
const min = dayjs(stop).diff(dayjs(start), "minute");
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
@@ -78,23 +78,32 @@ export default function TransactionsPage() {
<p className="mt-0.5 text-sm text-muted"> {data?.total ?? "—"} </p>
</div>
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => refetch()} aria-label="刷新">
<Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
{(["all", "active", "completed"] as const).map((s) => (
<button
key={s}
onClick={() => handleStatusChange(s)}
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
status === s
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
>
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
</button>
))} </div> </div>
{(["all", "active", "completed"] as const).map((s) => (
<button
key={s}
onClick={() => handleStatusChange(s)}
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
status === s
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
>
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
</button>
))}{" "}
</div>{" "}
</div>
</div>
<Table>
@@ -138,7 +147,7 @@ export default function TransactionsPage() {
)}
</Table.Cell>
<Table.Cell className="whitespace-nowrap text-sm">
{new Date(tx.startTimestamp).toLocaleString("zh-CN")}
{dayjs(tx.startTimestamp).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
<Table.Cell className="text-sm">
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}

View File

@@ -20,6 +20,7 @@ import {
import { CreditCard, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
type CreateForm = {
name: string;
@@ -452,7 +453,7 @@ export default function UsersPage() {
)}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(u.createdAt).toLocaleString("zh-CN")}
{dayjs(u.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
<Table.Cell>
<div className="flex justify-end gap-1">

View File

@@ -1,5 +1,8 @@
@import "tailwindcss";
@import "@heroui/styles";
@source inline("{bg,text,border,ring,stroke,fill}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("hover:{bg,text,border}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("data-[selected]:{bg,text,border}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
/*
* HeroUI Theme Customization
@@ -17,26 +20,26 @@
--accent: oklch(62.04% 0.1951 253.83);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0069 253.83);
--border: oklch(90.00% 0.0069 253.83);
--danger: oklch(65.32% 0.2360 25.74);
--border: oklch(90% 0.0069 253.83);
--danger: oklch(65.32% 0.236 25.74);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(94.00% 0.0069 253.83);
--default: oklch(94% 0.0069 253.83);
--default-foreground: oklch(21.03% 0.0059 253.83);
--field-background: oklch(100.00% 0.0034 253.83);
--field-background: oklch(100% 0.0034 253.83);
--field-foreground: oklch(21.03% 0.0069 253.83);
--field-placeholder: oklch(55.17% 0.0138 253.83);
--focus: oklch(62.04% 0.1951 253.83);
--foreground: oklch(21.03% 0.0069 253.83);
--muted: oklch(55.17% 0.0138 253.83);
--overlay: oklch(100.00% 0.0021 253.83);
--overlay: oklch(100% 0.0021 253.83);
--overlay-foreground: oklch(21.03% 0.0069 253.83);
--scrollbar: oklch(87.10% 0.0069 253.83);
--segment: oklch(100.00% 0.0069 253.83);
--scrollbar: oklch(87.1% 0.0069 253.83);
--segment: oklch(100% 0.0069 253.83);
--segment-foreground: oklch(21.03% 0.0069 253.83);
--separator: oklch(92.00% 0.0069 253.83);
--separator: oklch(92% 0.0069 253.83);
--success: oklch(73.29% 0.1962 150.81);
--success-foreground: oklch(21.03% 0.0059 150.81);
--surface: oklch(100.00% 0.0034 253.83);
--surface: oklch(100% 0.0034 253.83);
--surface-foreground: oklch(21.03% 0.0069 253.83);
--surface-secondary: oklch(95.24% 0.0055 253.83);
--surface-secondary-foreground: oklch(21.03% 0.0069 253.83);
@@ -60,29 +63,29 @@
/* Theme Colors (Dark Mode) */
--accent: oklch(62.04% 0.1951 253.83);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0069 253.83);
--border: oklch(28.00% 0.0069 253.83);
--danger: oklch(59.40% 0.1994 24.63);
--background: oklch(12% 0.0069 253.83);
--border: oklch(28% 0.0069 253.83);
--danger: oklch(59.4% 0.1994 24.63);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(27.40% 0.0069 253.83);
--default: oklch(27.4% 0.0069 253.83);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0138 253.83);
--field-foreground: oklch(99.11% 0.0069 253.83);
--field-placeholder: oklch(70.50% 0.0138 253.83);
--field-placeholder: oklch(70.5% 0.0138 253.83);
--focus: oklch(62.04% 0.1951 253.83);
--foreground: oklch(99.11% 0.0069 253.83);
--muted: oklch(70.50% 0.0138 253.83);
--muted: oklch(70.5% 0.0138 253.83);
--overlay: oklch(21.03% 0.0138 253.83);
--overlay-foreground: oklch(99.11% 0.0069 253.83);
--scrollbar: oklch(70.50% 0.0069 253.83);
--scrollbar: oklch(70.5% 0.0069 253.83);
--segment: oklch(39.64% 0.0069 253.83);
--segment-foreground: oklch(99.11% 0.0069 253.83);
--separator: oklch(25.00% 0.0069 253.83);
--separator: oklch(25% 0.0069 253.83);
--success: oklch(73.29% 0.1962 150.81);
--success-foreground: oklch(21.03% 0.0059 150.81);
--surface: oklch(21.03% 0.0138 253.83);
--surface-foreground: oklch(99.11% 0.0069 253.83);
--surface-secondary: oklch(25.70% 0.0103 253.83);
--surface-secondary: oklch(25.7% 0.0103 253.83);
--surface-secondary-foreground: oklch(99.11% 0.0069 253.83);
--surface-tertiary: oklch(27.21% 0.0103 253.83);
--surface-tertiary-foreground: oklch(99.11% 0.0069 253.83);

View File

@@ -154,9 +154,21 @@ export type PaginatedTransactions = {
// ── API functions ──────────────────────────────────────────────────────────
export type ChartRange = "30d" | "7d" | "24h";
export type ChartDataPoint = {
bucket: string;
energyKwh: number;
revenue: number;
transactions: number;
utilizationPct: number;
};
export const api = {
stats: {
get: () => apiFetch<Stats | UserStats>("/api/stats"),
chart: (range: ChartRange) =>
apiFetch<ChartDataPoint[]>(`/api/stats/chart?range=${range}`),
},
chargePoints: {
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),

8
apps/web/lib/dayjs.ts Normal file
View File

@@ -0,0 +1,8 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/zh-cn";
dayjs.extend(relativeTime);
dayjs.locale("zh-cn");
export default dayjs;

View File

@@ -13,8 +13,10 @@
"@heroui/styles": "3.0.0-beta.8",
"@internationalized/date": "^3.12.0",
"@tanstack/react-query": "catalog:",
"@tremor/react": "4.0.0-beta-tremor-v4.4",
"@types/qrcode": "^1.5.6",
"better-auth": "catalog:",
"dayjs": "catalog:",
"next": "16.1.6",
"qrcode.react": "^4.2.0",
"react": "19.2.3",