From 02a361488b68caf29234120b11a128c5dbb31a10 Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Wed, 11 Mar 2026 21:34:21 +0800 Subject: [PATCH] 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 --- apps/csms/package.json | 1 + apps/csms/src/index.ts | 2 + apps/csms/src/ocpp/actions/authorize.ts | 3 +- .../src/ocpp/actions/boot-notification.ts | 9 +- apps/csms/src/ocpp/actions/heartbeat.ts | 5 +- apps/csms/src/ocpp/actions/meter-values.ts | 3 +- .../src/ocpp/actions/start-transaction.ts | 11 +- .../src/ocpp/actions/status-notification.ts | 5 +- .../csms/src/ocpp/actions/stop-transaction.ts | 13 +- apps/csms/src/routes/charge-points.ts | 3 +- apps/csms/src/routes/id-tags.ts | 7 +- apps/csms/src/routes/stats-chart.ts | 168 ++++++++++++++ apps/csms/src/routes/transactions.ts | 11 +- apps/csms/src/routes/users.ts | 3 +- .../app/dashboard/charge-points/[id]/page.tsx | 36 +-- apps/web/app/dashboard/charge-points/page.tsx | 3 +- apps/web/app/dashboard/charge/page.tsx | 3 +- apps/web/app/dashboard/id-tags/page.tsx | 5 +- apps/web/app/dashboard/page.tsx | 206 +++++++++++++++++- apps/web/app/dashboard/settings/page.tsx | 7 +- apps/web/app/dashboard/transactions/page.tsx | 43 ++-- apps/web/app/dashboard/users/page.tsx | 3 +- apps/web/app/globals.css | 39 ++-- apps/web/lib/api.ts | 12 + apps/web/lib/dayjs.ts | 8 + apps/web/package.json | 2 + pnpm-workspace.yaml | 1 + 27 files changed, 502 insertions(+), 110 deletions(-) create mode 100644 apps/csms/src/routes/stats-chart.ts create mode 100644 apps/web/lib/dayjs.ts diff --git a/apps/csms/package.json b/apps/csms/package.json index 557c4ce..0be6a65 100644 --- a/apps/csms/package.json +++ b/apps/csms/package.json @@ -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", diff --git a/apps/csms/src/index.ts b/apps/csms/src/index.ts index fcad9bc..8b0fc78 100644 --- a/apps/csms/src/index.ts +++ b/apps/csms/src/index.ts @@ -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) diff --git a/apps/csms/src/ocpp/actions/authorize.ts b/apps/csms/src/ocpp/actions/authorize.ts index 5569879..b92e59f 100644 --- a/apps/csms/src/ocpp/actions/authorize.ts +++ b/apps/csms/src/ocpp/actions/authorize.ts @@ -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") { diff --git a/apps/csms/src/ocpp/actions/boot-notification.ts b/apps/csms/src/ocpp/actions/boot-notification.ts index e4a1e63..c3a9985 100644 --- a/apps/csms/src/ocpp/actions/boot-notification.ts +++ b/apps/csms/src/ocpp/actions/boot-notification.ts @@ -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, } diff --git a/apps/csms/src/ocpp/actions/heartbeat.ts b/apps/csms/src/ocpp/actions/heartbeat.ts index acdc1f9..ed90bf8 100644 --- a/apps/csms/src/ocpp/actions/heartbeat.ts +++ b/apps/csms/src/ocpp/actions/heartbeat.ts @@ -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(), } } diff --git a/apps/csms/src/ocpp/actions/meter-values.ts b/apps/csms/src/ocpp/actions/meter-values.ts index 5e4d83b..fd44d7b 100644 --- a/apps/csms/src/ocpp/actions/meter-values.ts +++ b/apps/csms/src/ocpp/actions/meter-values.ts @@ -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"], })); diff --git a/apps/csms/src/ocpp/actions/start-transaction.ts b/apps/csms/src/ocpp/actions/start-transaction.ts index 2b6ec25..606608e 100644 --- a/apps/csms/src/ocpp/actions/start-transaction.ts +++ b/apps/csms/src/ocpp/actions/start-transaction.ts @@ -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", diff --git a/apps/csms/src/ocpp/actions/status-notification.ts b/apps/csms/src/ocpp/actions/status-notification.ts index 2689a17..8f6e3a9 100644 --- a/apps/csms/src/ocpp/actions/status-notification.ts +++ b/apps/csms/src/ocpp/actions/status-notification.ts @@ -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 }) diff --git a/apps/csms/src/ocpp/actions/stop-transaction.ts b/apps/csms/src/ocpp/actions/stop-transaction.ts index f341c9d..25faf5c 100644 --- a/apps/csms/src/ocpp/actions/stop-transaction.ts +++ b/apps/csms/src/ocpp/actions/stop-transaction.ts @@ -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)); } diff --git a/apps/csms/src/routes/charge-points.ts b/apps/csms/src/routes/charge-points.ts index 78a7b68..c6a26ad 100644 --- a/apps/csms/src/routes/charge-points.ts +++ b/apps/csms/src/routes/charge-points.ts @@ -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) { diff --git a/apps/csms/src/routes/id-tags.ts b/apps/csms/src/routes/id-tags.ts index e715315..f14c53d 100644 --- a/apps/csms/src/routes/id-tags.ts +++ b/apps/csms/src/routes/id-tags.ts @@ -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(); diff --git a/apps/csms/src/routes/stats-chart.ts b/apps/csms/src/routes/stats-chart.ts new file mode 100644 index 0000000..c4047f8 --- /dev/null +++ b/apps/csms/src/routes/stats-chart.ts @@ -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(); + +/** + * 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`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; diff --git a/apps/csms/src/routes/transactions.ts b/apps/csms/src/routes/transactions.ts index a07d905..1dc30ec 100644 --- a/apps/csms/src/routes/transactions.ts +++ b/apps/csms/src/routes/transactions.ts @@ -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)); } diff --git a/apps/csms/src/routes/users.ts b/apps/csms/src/routes/users.ts index 79e7047..8a6065b 100644 --- a/apps/csms/src/routes/users.ts +++ b/apps/csms/src/routes/users.ts @@ -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({ diff --git a/apps/web/app/dashboard/charge-points/[id]/page.tsx b/apps/web/app/dashboard/charge-points/[id]/page.tsx index b7db530..e78a519 100644 --- a/apps/web/app/dashboard/charge-points/[id]/page.tsx +++ b/apps/web/app/dashboard/charge-points/[id]/page.tsx @@ -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
最后心跳
{cp.lastHeartbeatAt ? ( - + {relativeTime(cp.lastHeartbeatAt)} ) : ( @@ -355,7 +347,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
最后启动通知
{cp.lastBootNotificationAt ? ( - + {relativeTime(cp.lastBootNotificationAt)} ) : ( @@ -366,7 +358,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
注册时间
- {new Date(cp.createdAt).toLocaleDateString("zh-CN")} + {dayjs(cp.createdAt).format("YYYY/M/D")}
@@ -437,12 +429,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id {conn.info &&

{conn.info}

}

更新于{" "} - {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")}

))} @@ -489,12 +476,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id {tx.idTag} - {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")} {formatDuration(tx.startTimestamp, tx.stopTimestamp)} diff --git a/apps/web/app/dashboard/charge-points/page.tsx b/apps/web/app/dashboard/charge-points/page.tsx index 3408408..c03d05e 100644 --- a/apps/web/app/dashboard/charge-points/page.tsx +++ b/apps/web/app/dashboard/charge-points/page.tsx @@ -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 = { Available: "空闲中", @@ -473,7 +474,7 @@ export default function ChargePointsPage() { {cp.lastHeartbeatAt ? ( - new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN") + dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss") ) : ( )} diff --git a/apps/web/app/dashboard/charge/page.tsx b/apps/web/app/dashboard/charge/page.tsx index 3453b9e..0889c0c 100644 --- a/apps/web/app/dashboard/charge/page.tsx +++ b/apps/web/app/dashboard/charge/page.tsx @@ -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; diff --git a/apps/web/app/dashboard/id-tags/page.tsx b/apps/web/app/dashboard/id-tags/page.tsx index 6e7dcf5..0c9b2f7 100644 --- a/apps/web/app/dashboard/id-tags/page.tsx +++ b/apps/web/app/dashboard/id-tags/page.tsx @@ -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 = { Accepted: "success", @@ -529,7 +530,7 @@ export default function IdTagsPage() { )} {tag.expiryDate ? ( - new Date(tag.expiryDate).toLocaleDateString("zh-CN") + dayjs(tag.expiryDate).format("YYYY/M/D") ) : ( )} @@ -538,7 +539,7 @@ export default function IdTagsPage() { {tag.parentIdTag ?? } - {new Date(tag.createdAt).toLocaleString("zh-CN")} + {dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")} {isAdmin && ( diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 7f23dca..fd0044d 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -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 = { + economy: ["revenue", "energyKwh"], + activity: ["transactions", "utilizationPct"], +}; + +function TrendChart() { + const [range, setRange] = useState("7d"); + const [view, setView] = useState("economy"); + const [hidden, setHidden] = useState>(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({ + queryKey: ["stats-chart", range], + queryFn: () => api.stats.chart(range), + staleTime: 60_000, + }); + + return ( +
+ {/* Header */} +
+ {/* View toggle */} +
+ {(["economy", "activity"] as ChartView[]).map((v) => ( + + ))} +
+ {/* Range toggle */} +
+ {RANGES.map((r) => ( + + ))} +
+
+ + {/* Chart */} +
+ {isPending ? ( +
+ +
+ ) : ( + ({ ...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 ( +
+

{props.label}

+ {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 ( +
+ + {cfg.label} + + {cfg.fmt(entry.value as number)} + +
+ ); + })} +
+ ); + }} + showLegend={false} + showGridLines={true} + showYAxis={true} + showXAxis={true} + curveType="monotone" + showAnimation + className="h-56" + /> + )} + {/* Legend */} +
+ {allSeriesForView.map((key) => { + const cfg = SERIES_CONFIG[key]; + return ( + + ); + })} +
+
+
+ ); +} + // ── RecentTransactions ──────────────────────────────────────────────────── function RecentTransactions({ txns }: { txns: Transaction[] }) { @@ -369,6 +558,9 @@ export default function DashboardPage() { /> + {/* Trend chart */} + {isAdmin && } + {/* Detail panels */}
diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/page.tsx index b1d9213..936c276 100644 --- a/apps/web/app/dashboard/settings/page.tsx +++ b/apps/web/app/dashboard/settings/page.tsx @@ -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 */}

添加于{" "} - {new Date(pk.createdAt).toLocaleString("zh-CN", { - year: "numeric", - month: "short", - day: "numeric", - })} + {dayjs(pk.createdAt).format("YYYY年M月D日")}

diff --git a/apps/web/app/dashboard/transactions/page.tsx b/apps/web/app/dashboard/transactions/page.tsx index b9e82fe..5c3fe70 100644 --- a/apps/web/app/dashboard/transactions/page.tsx +++ b/apps/web/app/dashboard/transactions/page.tsx @@ -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() {

共 {data?.total ?? "—"} 条

-
- {(["all", "active", "completed"] as const).map((s) => ( - - ))}
+ {(["all", "active", "completed"] as const).map((s) => ( + + ))}{" "} + {" "} + @@ -138,7 +147,7 @@ export default function TransactionsPage() { )} - {new Date(tx.startTimestamp).toLocaleString("zh-CN")} + {dayjs(tx.startTimestamp).format("YYYY/M/D HH:mm:ss")} {formatDuration(tx.startTimestamp, tx.stopTimestamp)} diff --git a/apps/web/app/dashboard/users/page.tsx b/apps/web/app/dashboard/users/page.tsx index 68c8211..0e535b7 100644 --- a/apps/web/app/dashboard/users/page.tsx +++ b/apps/web/app/dashboard/users/page.tsx @@ -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() { )} - {new Date(u.createdAt).toLocaleString("zh-CN")} + {dayjs(u.createdAt).format("YYYY/M/D HH:mm:ss")}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7cca004..bf8073f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index b98ee5b..e34fdda 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -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("/api/stats"), + chart: (range: ChartRange) => + apiFetch(`/api/stats/chart?range=${range}`), }, chargePoints: { list: () => apiFetch("/api/charge-points"), diff --git a/apps/web/lib/dayjs.ts b/apps/web/lib/dayjs.ts new file mode 100644 index 0000000..7c56b35 --- /dev/null +++ b/apps/web/lib/dayjs.ts @@ -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; diff --git a/apps/web/package.json b/apps/web/package.json index f2be31a..d458a77 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6418ed0..9fb3a4d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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