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

@@ -17,6 +17,7 @@
"@hono/node-ws": "^1.2.0",
"@hono/zod-validator": "^0.7.6",
"better-auth": "catalog:",
"dayjs": "catalog:",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.6",

View File

@@ -9,6 +9,7 @@ import { showRoutes } from 'hono/dev'
import { auth } from './lib/auth.ts'
import { createOcppHandler } from './ocpp/handler.ts'
import statsRoutes from './routes/stats.ts'
import statsChartRoutes from './routes/stats-chart.ts'
import chargePointRoutes from './routes/charge-points.ts'
import transactionRoutes from './routes/transactions.ts'
import idTagRoutes from './routes/id-tags.ts'
@@ -51,6 +52,7 @@ app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
// REST API routes
app.route('/api/stats', statsRoutes)
app.route('/api/stats/chart', statsChartRoutes)
app.route('/api/charge-points', chargePointRoutes)
app.route('/api/transactions', transactionRoutes)
app.route('/api/id-tags', idTagRoutes)

View File

@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import type {
@@ -24,7 +25,7 @@ export async function resolveIdTagInfo(
if (!tag) return { status: "Invalid" };
if (tag.status === "Blocked") return { status: "Blocked" };
if (tag.expiryDate && tag.expiryDate < new Date()) {
if (tag.expiryDate && dayjs(tag.expiryDate).isBefore(dayjs())) {
return { status: "Expired", expiryDate: tag.expiryDate.toISOString() };
}
if (tag.status !== "Accepted") {

View File

@@ -1,4 +1,5 @@
import { useDrizzle } from '@/lib/db.js'
import dayjs from 'dayjs'
import { chargePoint } from '@/db/schema.js'
import type {
BootNotificationRequest,
@@ -30,7 +31,7 @@ export async function handleBootNotification(
// New, unknown devices start as Pending — admin must manually accept them
registrationStatus: 'Pending',
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
lastBootNotificationAt: new Date(),
lastBootNotificationAt: dayjs().toDate(),
})
.onConflictDoUpdate({
target: chargePoint.chargePointIdentifier,
@@ -45,8 +46,8 @@ export async function handleBootNotification(
meterSerialNumber: payload.meterSerialNumber ?? null,
// Do NOT override registrationStatus — preserve whatever the admin set
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
lastBootNotificationAt: new Date(),
updatedAt: new Date(),
lastBootNotificationAt: dayjs().toDate(),
updatedAt: dayjs().toDate(),
},
})
.returning()
@@ -57,7 +58,7 @@ export async function handleBootNotification(
console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`)
return {
currentTime: new Date().toISOString(),
currentTime: dayjs().toISOString(),
interval: DEFAULT_HEARTBEAT_INTERVAL,
status,
}

View File

@@ -1,4 +1,5 @@
import { eq } from 'drizzle-orm'
import dayjs from 'dayjs'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint } from '@/db/schema.js'
import type {
@@ -15,10 +16,10 @@ export async function handleHeartbeat(
await db
.update(chargePoint)
.set({ lastHeartbeatAt: new Date() })
.set({ lastHeartbeatAt: dayjs().toDate() })
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
return {
currentTime: new Date().toISOString(),
currentTime: dayjs().toISOString(),
}
}

View File

@@ -1,4 +1,5 @@
import { and, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, meterValue } from "@/db/schema.js";
import type { MeterValuesRequest, MeterValuesResponse, OcppConnectionContext } from "../types.ts";
@@ -38,7 +39,7 @@ export async function handleMeterValues(
connectorId: conn.id,
chargePointId: cp.id,
connectorNumber: payload.connectorId,
timestamp: new Date(mv.timestamp),
timestamp: dayjs(mv.timestamp).toDate(),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
}));

View File

@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, transaction } from "@/db/schema.js";
import type {
@@ -35,16 +36,16 @@ export async function handleStartTransaction(
connectorId: payload.connectorId,
status: "Charging",
errorCode: "NoError",
lastStatusAt: new Date(),
lastStatusAt: dayjs().toDate(),
})
.onConflictDoUpdate({
target: [connector.chargePointId, connector.connectorId],
set: { status: "Charging", updatedAt: new Date() },
set: { status: "Charging", updatedAt: dayjs().toDate() },
})
.returning({ id: connector.id });
const rejected = idTagInfo.status !== "Accepted";
const now = new Date();
const now = dayjs();
// Insert transaction record regardless of auth status (OCPP spec requirement)
const [tx] = await db
@@ -55,12 +56,12 @@ export async function handleStartTransaction(
connectorNumber: payload.connectorId,
idTag: payload.idTag,
idTagStatus: idTagInfo.status,
startTimestamp: new Date(payload.timestamp),
startTimestamp: dayjs(payload.timestamp).toDate(),
startMeterValue: payload.meterStart,
reservationId: payload.reservationId ?? null,
// If rejected, immediately close the transaction so it doesn't appear as in-progress
...(rejected && {
stopTimestamp: now,
stopTimestamp: now.toDate(),
stopMeterValue: payload.meterStart,
chargeAmount: 0,
stopReason: "DeAuthorized",

View File

@@ -1,4 +1,5 @@
import { eq } from 'drizzle-orm'
import dayjs from 'dayjs'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint, connector, connectorStatusHistory } from '@/db/schema.js'
import type {
@@ -54,7 +55,7 @@ export async function handleStatusNotification(
throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`)
}
const statusTimestamp = payload.timestamp ? new Date(payload.timestamp) : new Date()
const statusTimestamp = payload.timestamp ? dayjs(payload.timestamp).toDate() : dayjs().toDate()
const connStatus = payload.status as ConnectorStatus
const connErrorCode = payload.errorCode as ConnectorErrorCode
@@ -81,7 +82,7 @@ export async function handleStatusNotification(
vendorId: payload.vendorId ?? null,
vendorErrorCode: payload.vendorErrorCode ?? null,
lastStatusAt: statusTimestamp,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
},
})
.returning({ id: connector.id })

View File

@@ -1,4 +1,5 @@
import { eq, sql } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
import type {
@@ -18,11 +19,11 @@ export async function handleStopTransaction(
const [tx] = await db
.update(transaction)
.set({
stopTimestamp: new Date(payload.timestamp),
stopTimestamp: dayjs(payload.timestamp).toDate(),
stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
})
.where(eq(transaction.id, payload.transactionId))
.returning();
@@ -35,7 +36,7 @@ export async function handleStopTransaction(
// Set connector back to Available
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.set({ status: "Available", updatedAt: dayjs().toDate() })
.where(eq(connector.id, tx.connectorId));
// Store embedded meter values (transactionData)
@@ -49,7 +50,7 @@ export async function handleStopTransaction(
connectorId: tx.connectorId,
chargePointId: tx.chargePointId,
connectorNumber: tx.connectorNumber,
timestamp: new Date(mv.timestamp),
timestamp: dayjs(mv.timestamp).toDate(),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
},
@@ -76,7 +77,7 @@ export async function handleStopTransaction(
// Always record the charge amount (0 if free)
await db
.update(transaction)
.set({ chargeAmount: feeFen, updatedAt: new Date() })
.set({ chargeAmount: feeFen, updatedAt: dayjs().toDate() })
.where(eq(transaction.id, tx.id));
if (feeFen > 0) {
@@ -84,7 +85,7 @@ export async function handleStopTransaction(
.update(idTag)
.set({
balance: sql`${idTag.balance} - ${feeFen}`,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
})
.where(eq(idTag.idTag, tx.idTag));
}

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
@@ -135,7 +136,7 @@ app.patch("/:id", async (c) => {
chargePointVendor?: string;
chargePointModel?: string;
updatedAt: Date;
} = { updatedAt: new Date() };
} = { updatedAt: dayjs().toDate() };
if (body.feePerKwh !== undefined) {
if (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0) {

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
@@ -69,7 +70,7 @@ app.post("/", async (c) => {
.insert(idTag)
.values({
...parsed.data,
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : null,
expiryDate: parsed.data.expiryDate ? dayjs(parsed.data.expiryDate).toDate() : null,
})
.returning();
return c.json(created, 201);
@@ -120,8 +121,8 @@ app.patch("/:id", async (c) => {
.update(idTag)
.set({
...parsed.data,
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : undefined,
updatedAt: new Date(),
expiryDate: parsed.data.expiryDate ? dayjs(parsed.data.expiryDate).toDate() : undefined,
updatedAt: dayjs().toDate(),
})
.where(eq(idTag.idTag, tagId))
.returning();

View File

@@ -0,0 +1,168 @@
import { Hono } from "hono";
import { sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { transaction, connector } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono<HonoEnv>();
/**
* GET /api/stats/chart?range=30d|7d|24h
* 返回时序图表数据按日30d/7d或小时24h分组
* 仅管理员可访问
*/
app.get("/", async (c) => {
const currentUser = c.get("user");
if (!currentUser || currentUser.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
const range = c.req.query("range") ?? "7d";
const db = useDrizzle();
// 真实插口数量connectorId >= 1至少为 1 防止除零
const [{ cnt }] = await db
.select({ cnt: sql<number>`greatest(count(*)::int, 1)` })
.from(connector)
.where(sql`${connector.connectorId} >= 1`);
if (range === "24h") {
// 按小时分组,最近 24 小时
const rows = await db.execute(sql`
SELECT
to_char(
date_trunc('hour', generate_series),
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
) AS bucket,
coalesce(
(
SELECT coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::float / 1000
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS energy_kwh,
coalesce(
(
SELECT coalesce(sum(${transaction.chargeAmount}), 0)::float / 100
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS revenue,
coalesce(
(
SELECT count(*)::int
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS tx_count,
round(coalesce(
(
SELECT extract(epoch from sum(
least(coalesce(t.stop_timestamp, now()), date_trunc('hour', generate_series) + interval '1 hour')
- greatest(t.start_timestamp, date_trunc('hour', generate_series))
)) / (${sql.raw(String(cnt))} * 3600.0) * 100
FROM "transaction" t
WHERE t.start_timestamp < date_trunc('hour', generate_series) + interval '1 hour'
AND (t.stop_timestamp IS NULL OR t.stop_timestamp > date_trunc('hour', generate_series))
), 0
)::numeric, 1) AS utilization_pct
FROM generate_series(
date_trunc('hour', now() AT TIME ZONE 'UTC') - interval '23 hours',
date_trunc('hour', now() AT TIME ZONE 'UTC'),
interval '1 hour'
) AS generate_series
ORDER BY bucket ASC
`);
type Row24h = {
bucket: string;
energy_kwh: number;
revenue: number;
tx_count: number;
utilization_pct: number;
};
return c.json(
(rows.rows as Row24h[]).map((r) => ({
bucket: r.bucket,
energyKwh: Number(r.energy_kwh),
revenue: Number(r.revenue),
transactions: Number(r.tx_count),
utilizationPct: Number(r.utilization_pct),
})),
);
}
// 按天分组7d 或 30d
const days = range === "30d" ? 29 : 6;
const rows = await db.execute(sql`
SELECT
to_char(
date_trunc('day', generate_series),
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
) AS bucket,
coalesce(
(
SELECT coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::float / 1000
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS energy_kwh,
coalesce(
(
SELECT coalesce(sum(${transaction.chargeAmount}), 0)::float / 100
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS revenue,
coalesce(
(
SELECT count(*)::int
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS tx_count,
round(coalesce(
(
SELECT extract(epoch from sum(
least(coalesce(t.stop_timestamp, now()), date_trunc('day', generate_series) + interval '1 day')
- greatest(t.start_timestamp, date_trunc('day', generate_series))
)) / (${sql.raw(String(cnt))} * 86400.0) * 100
FROM "transaction" t
WHERE t.start_timestamp < date_trunc('day', generate_series) + interval '1 day'
AND (t.stop_timestamp IS NULL OR t.stop_timestamp > date_trunc('day', generate_series))
), 0
)::numeric, 1) AS utilization_pct
FROM generate_series(
date_trunc('day', now() AT TIME ZONE 'UTC') - interval '${sql.raw(String(days))} days',
date_trunc('day', now() AT TIME ZONE 'UTC'),
interval '1 day'
) AS generate_series
ORDER BY bucket ASC
`);
type RowDay = {
bucket: string;
energy_kwh: number;
revenue: number;
tx_count: number;
utilization_pct: number;
};
return c.json(
(rows.rows as RowDay[]).map((r) => ({
bucket: r.bucket,
energyKwh: Number(r.energy_kwh),
revenue: Number(r.revenue),
transactions: Number(r.tx_count),
utilizationPct: Number(r.utilization_pct),
})),
);
});
export default app;

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import { user } from "@/db/auth-schema.js";
@@ -206,7 +207,7 @@ app.post("/:id/stop", async (c) => {
if (!row) return c.json({ error: "Not found" }, 404);
if (row.transaction.stopTimestamp) return c.json({ error: "Transaction already stopped" }, 409);
const now = new Date();
const now = dayjs();
// Try to send RemoteStopTransaction via OCPP if the charge point is online
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
@@ -233,11 +234,11 @@ app.post("/:id/stop", async (c) => {
const [updated] = await db
.update(transaction)
.set({
stopTimestamp: now,
stopTimestamp: now.toDate(),
stopMeterValue,
stopReason: "Remote",
chargeAmount: feeFen,
updatedAt: now,
updatedAt: now.toDate(),
})
.where(eq(transaction.id, id))
.returning();
@@ -247,7 +248,7 @@ app.post("/:id/stop", async (c) => {
.update(idTag)
.set({
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
updatedAt: now,
updatedAt: now.toDate(),
})
.where(eq(idTag.idTag, row.transaction.idTag));
}
@@ -278,7 +279,7 @@ app.delete("/:id", async (c) => {
if (!row.transaction.stopTimestamp) {
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.set({ status: "Available", updatedAt: dayjs().toDate() })
.where(eq(connector.id, row.transaction.connectorId));
}

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { user } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
@@ -57,7 +58,7 @@ app.patch("/:id", zValidator("json", userUpdateSchema), async (c) => {
.update(user)
.set({
...body,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
})
.where(eq(user.id, userId))
.returning({

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,7 +78,14 @@ 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">
@@ -94,7 +101,9 @@ export default function TransactionsPage() {
>
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
</button>
))} </div> </div>
))}{" "}
</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",

View File

@@ -6,6 +6,7 @@ catalog:
"@tanstack/react-query": ^5.90.21
"@better-auth/passkey": "^1.5.4"
"better-auth": "^1.5.4"
"dayjs": "^1.11.19"
onlyBuiltDependencies:
- sharp