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:
@@ -17,6 +17,7 @@
|
|||||||
"@hono/node-ws": "^1.2.0",
|
"@hono/node-ws": "^1.2.0",
|
||||||
"@hono/zod-validator": "^0.7.6",
|
"@hono/zod-validator": "^0.7.6",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
|
"dayjs": "catalog:",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"hono": "^4.10.6",
|
"hono": "^4.10.6",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { showRoutes } from 'hono/dev'
|
|||||||
import { auth } from './lib/auth.ts'
|
import { auth } from './lib/auth.ts'
|
||||||
import { createOcppHandler } from './ocpp/handler.ts'
|
import { createOcppHandler } from './ocpp/handler.ts'
|
||||||
import statsRoutes from './routes/stats.ts'
|
import statsRoutes from './routes/stats.ts'
|
||||||
|
import statsChartRoutes from './routes/stats-chart.ts'
|
||||||
import chargePointRoutes from './routes/charge-points.ts'
|
import chargePointRoutes from './routes/charge-points.ts'
|
||||||
import transactionRoutes from './routes/transactions.ts'
|
import transactionRoutes from './routes/transactions.ts'
|
||||||
import idTagRoutes from './routes/id-tags.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
|
// REST API routes
|
||||||
app.route('/api/stats', statsRoutes)
|
app.route('/api/stats', statsRoutes)
|
||||||
|
app.route('/api/stats/chart', statsChartRoutes)
|
||||||
app.route('/api/charge-points', chargePointRoutes)
|
app.route('/api/charge-points', chargePointRoutes)
|
||||||
app.route('/api/transactions', transactionRoutes)
|
app.route('/api/transactions', transactionRoutes)
|
||||||
app.route('/api/id-tags', idTagRoutes)
|
app.route('/api/id-tags', idTagRoutes)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { idTag } from "@/db/schema.js";
|
import { idTag } from "@/db/schema.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -24,7 +25,7 @@ export async function resolveIdTagInfo(
|
|||||||
|
|
||||||
if (!tag) return { status: "Invalid" };
|
if (!tag) return { status: "Invalid" };
|
||||||
if (tag.status === "Blocked") return { status: "Blocked" };
|
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() };
|
return { status: "Expired", expiryDate: tag.expiryDate.toISOString() };
|
||||||
}
|
}
|
||||||
if (tag.status !== "Accepted") {
|
if (tag.status !== "Accepted") {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useDrizzle } from '@/lib/db.js'
|
import { useDrizzle } from '@/lib/db.js'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { chargePoint } from '@/db/schema.js'
|
import { chargePoint } from '@/db/schema.js'
|
||||||
import type {
|
import type {
|
||||||
BootNotificationRequest,
|
BootNotificationRequest,
|
||||||
@@ -30,7 +31,7 @@ export async function handleBootNotification(
|
|||||||
// New, unknown devices start as Pending — admin must manually accept them
|
// New, unknown devices start as Pending — admin must manually accept them
|
||||||
registrationStatus: 'Pending',
|
registrationStatus: 'Pending',
|
||||||
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
|
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
|
||||||
lastBootNotificationAt: new Date(),
|
lastBootNotificationAt: dayjs().toDate(),
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: chargePoint.chargePointIdentifier,
|
target: chargePoint.chargePointIdentifier,
|
||||||
@@ -45,8 +46,8 @@ export async function handleBootNotification(
|
|||||||
meterSerialNumber: payload.meterSerialNumber ?? null,
|
meterSerialNumber: payload.meterSerialNumber ?? null,
|
||||||
// Do NOT override registrationStatus — preserve whatever the admin set
|
// Do NOT override registrationStatus — preserve whatever the admin set
|
||||||
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
|
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
|
||||||
lastBootNotificationAt: new Date(),
|
lastBootNotificationAt: dayjs().toDate(),
|
||||||
updatedAt: new Date(),
|
updatedAt: dayjs().toDate(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
@@ -57,7 +58,7 @@ export async function handleBootNotification(
|
|||||||
console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`)
|
console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentTime: new Date().toISOString(),
|
currentTime: dayjs().toISOString(),
|
||||||
interval: DEFAULT_HEARTBEAT_INTERVAL,
|
interval: DEFAULT_HEARTBEAT_INTERVAL,
|
||||||
status,
|
status,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { useDrizzle } from '@/lib/db.js'
|
import { useDrizzle } from '@/lib/db.js'
|
||||||
import { chargePoint } from '@/db/schema.js'
|
import { chargePoint } from '@/db/schema.js'
|
||||||
import type {
|
import type {
|
||||||
@@ -15,10 +16,10 @@ export async function handleHeartbeat(
|
|||||||
|
|
||||||
await db
|
await db
|
||||||
.update(chargePoint)
|
.update(chargePoint)
|
||||||
.set({ lastHeartbeatAt: new Date() })
|
.set({ lastHeartbeatAt: dayjs().toDate() })
|
||||||
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
|
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentTime: new Date().toISOString(),
|
currentTime: dayjs().toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { chargePoint, connector, meterValue } from "@/db/schema.js";
|
import { chargePoint, connector, meterValue } from "@/db/schema.js";
|
||||||
import type { MeterValuesRequest, MeterValuesResponse, OcppConnectionContext } from "../types.ts";
|
import type { MeterValuesRequest, MeterValuesResponse, OcppConnectionContext } from "../types.ts";
|
||||||
@@ -38,7 +39,7 @@ export async function handleMeterValues(
|
|||||||
connectorId: conn.id,
|
connectorId: conn.id,
|
||||||
chargePointId: cp.id,
|
chargePointId: cp.id,
|
||||||
connectorNumber: payload.connectorId,
|
connectorNumber: payload.connectorId,
|
||||||
timestamp: new Date(mv.timestamp),
|
timestamp: dayjs(mv.timestamp).toDate(),
|
||||||
sampledValues:
|
sampledValues:
|
||||||
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
|
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { chargePoint, connector, transaction } from "@/db/schema.js";
|
import { chargePoint, connector, transaction } from "@/db/schema.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -35,16 +36,16 @@ export async function handleStartTransaction(
|
|||||||
connectorId: payload.connectorId,
|
connectorId: payload.connectorId,
|
||||||
status: "Charging",
|
status: "Charging",
|
||||||
errorCode: "NoError",
|
errorCode: "NoError",
|
||||||
lastStatusAt: new Date(),
|
lastStatusAt: dayjs().toDate(),
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [connector.chargePointId, connector.connectorId],
|
target: [connector.chargePointId, connector.connectorId],
|
||||||
set: { status: "Charging", updatedAt: new Date() },
|
set: { status: "Charging", updatedAt: dayjs().toDate() },
|
||||||
})
|
})
|
||||||
.returning({ id: connector.id });
|
.returning({ id: connector.id });
|
||||||
|
|
||||||
const rejected = idTagInfo.status !== "Accepted";
|
const rejected = idTagInfo.status !== "Accepted";
|
||||||
const now = new Date();
|
const now = dayjs();
|
||||||
|
|
||||||
// Insert transaction record regardless of auth status (OCPP spec requirement)
|
// Insert transaction record regardless of auth status (OCPP spec requirement)
|
||||||
const [tx] = await db
|
const [tx] = await db
|
||||||
@@ -55,12 +56,12 @@ export async function handleStartTransaction(
|
|||||||
connectorNumber: payload.connectorId,
|
connectorNumber: payload.connectorId,
|
||||||
idTag: payload.idTag,
|
idTag: payload.idTag,
|
||||||
idTagStatus: idTagInfo.status,
|
idTagStatus: idTagInfo.status,
|
||||||
startTimestamp: new Date(payload.timestamp),
|
startTimestamp: dayjs(payload.timestamp).toDate(),
|
||||||
startMeterValue: payload.meterStart,
|
startMeterValue: payload.meterStart,
|
||||||
reservationId: payload.reservationId ?? null,
|
reservationId: payload.reservationId ?? null,
|
||||||
// If rejected, immediately close the transaction so it doesn't appear as in-progress
|
// If rejected, immediately close the transaction so it doesn't appear as in-progress
|
||||||
...(rejected && {
|
...(rejected && {
|
||||||
stopTimestamp: now,
|
stopTimestamp: now.toDate(),
|
||||||
stopMeterValue: payload.meterStart,
|
stopMeterValue: payload.meterStart,
|
||||||
chargeAmount: 0,
|
chargeAmount: 0,
|
||||||
stopReason: "DeAuthorized",
|
stopReason: "DeAuthorized",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { useDrizzle } from '@/lib/db.js'
|
import { useDrizzle } from '@/lib/db.js'
|
||||||
import { chargePoint, connector, connectorStatusHistory } from '@/db/schema.js'
|
import { chargePoint, connector, connectorStatusHistory } from '@/db/schema.js'
|
||||||
import type {
|
import type {
|
||||||
@@ -54,7 +55,7 @@ export async function handleStatusNotification(
|
|||||||
throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`)
|
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 connStatus = payload.status as ConnectorStatus
|
||||||
const connErrorCode = payload.errorCode as ConnectorErrorCode
|
const connErrorCode = payload.errorCode as ConnectorErrorCode
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ export async function handleStatusNotification(
|
|||||||
vendorId: payload.vendorId ?? null,
|
vendorId: payload.vendorId ?? null,
|
||||||
vendorErrorCode: payload.vendorErrorCode ?? null,
|
vendorErrorCode: payload.vendorErrorCode ?? null,
|
||||||
lastStatusAt: statusTimestamp,
|
lastStatusAt: statusTimestamp,
|
||||||
updatedAt: new Date(),
|
updatedAt: dayjs().toDate(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.returning({ id: connector.id })
|
.returning({ id: connector.id })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
|
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -18,11 +19,11 @@ export async function handleStopTransaction(
|
|||||||
const [tx] = await db
|
const [tx] = await db
|
||||||
.update(transaction)
|
.update(transaction)
|
||||||
.set({
|
.set({
|
||||||
stopTimestamp: new Date(payload.timestamp),
|
stopTimestamp: dayjs(payload.timestamp).toDate(),
|
||||||
stopMeterValue: payload.meterStop,
|
stopMeterValue: payload.meterStop,
|
||||||
stopIdTag: payload.idTag ?? null,
|
stopIdTag: payload.idTag ?? null,
|
||||||
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
|
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
|
||||||
updatedAt: new Date(),
|
updatedAt: dayjs().toDate(),
|
||||||
})
|
})
|
||||||
.where(eq(transaction.id, payload.transactionId))
|
.where(eq(transaction.id, payload.transactionId))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -35,7 +36,7 @@ export async function handleStopTransaction(
|
|||||||
// Set connector back to Available
|
// Set connector back to Available
|
||||||
await db
|
await db
|
||||||
.update(connector)
|
.update(connector)
|
||||||
.set({ status: "Available", updatedAt: new Date() })
|
.set({ status: "Available", updatedAt: dayjs().toDate() })
|
||||||
.where(eq(connector.id, tx.connectorId));
|
.where(eq(connector.id, tx.connectorId));
|
||||||
|
|
||||||
// Store embedded meter values (transactionData)
|
// Store embedded meter values (transactionData)
|
||||||
@@ -49,7 +50,7 @@ export async function handleStopTransaction(
|
|||||||
connectorId: tx.connectorId,
|
connectorId: tx.connectorId,
|
||||||
chargePointId: tx.chargePointId,
|
chargePointId: tx.chargePointId,
|
||||||
connectorNumber: tx.connectorNumber,
|
connectorNumber: tx.connectorNumber,
|
||||||
timestamp: new Date(mv.timestamp),
|
timestamp: dayjs(mv.timestamp).toDate(),
|
||||||
sampledValues:
|
sampledValues:
|
||||||
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["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)
|
// Always record the charge amount (0 if free)
|
||||||
await db
|
await db
|
||||||
.update(transaction)
|
.update(transaction)
|
||||||
.set({ chargeAmount: feeFen, updatedAt: new Date() })
|
.set({ chargeAmount: feeFen, updatedAt: dayjs().toDate() })
|
||||||
.where(eq(transaction.id, tx.id));
|
.where(eq(transaction.id, tx.id));
|
||||||
|
|
||||||
if (feeFen > 0) {
|
if (feeFen > 0) {
|
||||||
@@ -84,7 +85,7 @@ export async function handleStopTransaction(
|
|||||||
.update(idTag)
|
.update(idTag)
|
||||||
.set({
|
.set({
|
||||||
balance: sql`${idTag.balance} - ${feeFen}`,
|
balance: sql`${idTag.balance} - ${feeFen}`,
|
||||||
updatedAt: new Date(),
|
updatedAt: dayjs().toDate(),
|
||||||
})
|
})
|
||||||
.where(eq(idTag.idTag, tx.idTag));
|
.where(eq(idTag.idTag, tx.idTag));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { desc, eq, sql } from "drizzle-orm";
|
import { desc, eq, sql } from "drizzle-orm";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { chargePoint, connector } from "@/db/schema.js";
|
import { chargePoint, connector } from "@/db/schema.js";
|
||||||
import type { HonoEnv } from "@/types/hono.ts";
|
import type { HonoEnv } from "@/types/hono.ts";
|
||||||
@@ -135,7 +136,7 @@ app.patch("/:id", async (c) => {
|
|||||||
chargePointVendor?: string;
|
chargePointVendor?: string;
|
||||||
chargePointModel?: string;
|
chargePointModel?: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} = { updatedAt: new Date() };
|
} = { updatedAt: dayjs().toDate() };
|
||||||
|
|
||||||
if (body.feePerKwh !== undefined) {
|
if (body.feePerKwh !== undefined) {
|
||||||
if (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0) {
|
if (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { idTag } from "@/db/schema.js";
|
import { idTag } from "@/db/schema.js";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
@@ -69,7 +70,7 @@ app.post("/", async (c) => {
|
|||||||
.insert(idTag)
|
.insert(idTag)
|
||||||
.values({
|
.values({
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : null,
|
expiryDate: parsed.data.expiryDate ? dayjs(parsed.data.expiryDate).toDate() : null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return c.json(created, 201);
|
return c.json(created, 201);
|
||||||
@@ -120,8 +121,8 @@ app.patch("/:id", async (c) => {
|
|||||||
.update(idTag)
|
.update(idTag)
|
||||||
.set({
|
.set({
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : undefined,
|
expiryDate: parsed.data.expiryDate ? dayjs(parsed.data.expiryDate).toDate() : undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: dayjs().toDate(),
|
||||||
})
|
})
|
||||||
.where(eq(idTag.idTag, tagId))
|
.where(eq(idTag.idTag, tagId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
168
apps/csms/src/routes/stats-chart.ts
Normal file
168
apps/csms/src/routes/stats-chart.ts
Normal 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;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
|
import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
|
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
|
||||||
import { user } from "@/db/auth-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) return c.json({ error: "Not found" }, 404);
|
||||||
if (row.transaction.stopTimestamp) return c.json({ error: "Transaction already stopped" }, 409);
|
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
|
// Try to send RemoteStopTransaction via OCPP if the charge point is online
|
||||||
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
|
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
|
||||||
@@ -233,11 +234,11 @@ app.post("/:id/stop", async (c) => {
|
|||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(transaction)
|
.update(transaction)
|
||||||
.set({
|
.set({
|
||||||
stopTimestamp: now,
|
stopTimestamp: now.toDate(),
|
||||||
stopMeterValue,
|
stopMeterValue,
|
||||||
stopReason: "Remote",
|
stopReason: "Remote",
|
||||||
chargeAmount: feeFen,
|
chargeAmount: feeFen,
|
||||||
updatedAt: now,
|
updatedAt: now.toDate(),
|
||||||
})
|
})
|
||||||
.where(eq(transaction.id, id))
|
.where(eq(transaction.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -247,7 +248,7 @@ app.post("/:id/stop", async (c) => {
|
|||||||
.update(idTag)
|
.update(idTag)
|
||||||
.set({
|
.set({
|
||||||
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
|
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
|
||||||
updatedAt: now,
|
updatedAt: now.toDate(),
|
||||||
})
|
})
|
||||||
.where(eq(idTag.idTag, row.transaction.idTag));
|
.where(eq(idTag.idTag, row.transaction.idTag));
|
||||||
}
|
}
|
||||||
@@ -278,7 +279,7 @@ app.delete("/:id", async (c) => {
|
|||||||
if (!row.transaction.stopTimestamp) {
|
if (!row.transaction.stopTimestamp) {
|
||||||
await db
|
await db
|
||||||
.update(connector)
|
.update(connector)
|
||||||
.set({ status: "Available", updatedAt: new Date() })
|
.set({ status: "Available", updatedAt: dayjs().toDate() })
|
||||||
.where(eq(connector.id, row.transaction.connectorId));
|
.where(eq(connector.id, row.transaction.connectorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { user } from "@/db/schema.js";
|
import { user } from "@/db/schema.js";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
@@ -57,7 +58,7 @@ app.patch("/:id", zValidator("json", userUpdateSchema), async (c) => {
|
|||||||
.update(user)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
...body,
|
...body,
|
||||||
updatedAt: new Date(),
|
updatedAt: dayjs().toDate(),
|
||||||
})
|
})
|
||||||
.where(eq(user.id, userId))
|
.where(eq(user.id, userId))
|
||||||
.returning({
|
.returning({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { ArrowLeft, Pencil, PlugConnection, ArrowRotateRight } from "@gravity-ui/icons";
|
import { ArrowLeft, Pencil, PlugConnection, ArrowRotateRight } from "@gravity-ui/icons";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
|
||||||
// ── Status maps ────────────────────────────────────────────────────────────
|
// ── Status maps ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -60,8 +61,7 @@ const TX_LIMIT = 10;
|
|||||||
|
|
||||||
function formatDuration(start: string, stop: string | null): string {
|
function formatDuration(start: string, stop: string | null): string {
|
||||||
if (!stop) return "进行中";
|
if (!stop) return "进行中";
|
||||||
const ms = new Date(stop).getTime() - new Date(start).getTime();
|
const min = dayjs(stop).diff(dayjs(start), "minute");
|
||||||
const min = Math.floor(ms / 60000);
|
|
||||||
if (min < 60) return `${min} 分钟`;
|
if (min < 60) return `${min} 分钟`;
|
||||||
const h = Math.floor(min / 60);
|
const h = Math.floor(min / 60);
|
||||||
const m = min % 60;
|
const m = min % 60;
|
||||||
@@ -69,15 +69,7 @@ function formatDuration(start: string, stop: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(iso: string): string {
|
function relativeTime(iso: string): string {
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
return dayjs(iso).fromNow();
|
||||||
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} 天前`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit form type ─────────────────────────────────────────────────────────
|
// ── Edit form type ─────────────────────────────────────────────────────────
|
||||||
@@ -155,7 +147,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
// Online if last heartbeat within 3× interval
|
// Online if last heartbeat within 3× interval
|
||||||
const isOnline =
|
const isOnline =
|
||||||
cp?.lastHeartbeatAt != null &&
|
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 { data: sessionData } = useSession();
|
||||||
const isAdmin = sessionData?.user?.role === "admin";
|
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>
|
<dt className="shrink-0 text-sm text-muted">最后心跳</dt>
|
||||||
<dd className="text-right text-sm text-foreground">
|
<dd className="text-right text-sm text-foreground">
|
||||||
{cp.lastHeartbeatAt ? (
|
{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)}
|
{relativeTime(cp.lastHeartbeatAt)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -355,7 +347,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
<dt className="shrink-0 text-sm text-muted">最后启动通知</dt>
|
<dt className="shrink-0 text-sm text-muted">最后启动通知</dt>
|
||||||
<dd className="text-right text-sm text-foreground">
|
<dd className="text-right text-sm text-foreground">
|
||||||
{cp.lastBootNotificationAt ? (
|
{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)}
|
{relativeTime(cp.lastBootNotificationAt)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -366,7 +358,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
<div className="flex items-center justify-between gap-4 py-2">
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
<dt className="shrink-0 text-sm text-muted">注册时间</dt>
|
<dt className="shrink-0 text-sm text-muted">注册时间</dt>
|
||||||
<dd className="text-sm text-foreground">
|
<dd className="text-sm text-foreground">
|
||||||
{new Date(cp.createdAt).toLocaleDateString("zh-CN")}
|
{dayjs(cp.createdAt).format("YYYY/M/D")}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -437,12 +429,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
{conn.info && <p className="text-xs text-muted">{conn.info}</p>}
|
{conn.info && <p className="text-xs text-muted">{conn.info}</p>}
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
更新于{" "}
|
更新于{" "}
|
||||||
{new Date(conn.lastStatusAt).toLocaleString("zh-CN", {
|
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -489,12 +476,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
|
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
|
||||||
<Table.Cell className="tabular-nums text-sm">
|
<Table.Cell className="tabular-nums text-sm">
|
||||||
{new Date(tx.startTimestamp).toLocaleString("zh-CN", {
|
{dayjs(tx.startTimestamp).format("MM/DD HH:mm")}
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{formatDuration(tx.startTimestamp, tx.stopTimestamp)}</Table.Cell>
|
<Table.Cell>{formatDuration(tx.startTimestamp, tx.stopTimestamp)}</Table.Cell>
|
||||||
<Table.Cell className="tabular-nums">
|
<Table.Cell className="tabular-nums">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import Link from "next/link";
|
|||||||
import { ScrollFade } from "@/components/scroll-fade";
|
import { ScrollFade } from "@/components/scroll-fade";
|
||||||
import { api, type ChargePoint } from "@/lib/api";
|
import { api, type ChargePoint } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
|
||||||
const statusLabelMap: Record<string, string> = {
|
const statusLabelMap: Record<string, string> = {
|
||||||
Available: "空闲中",
|
Available: "空闲中",
|
||||||
@@ -473,7 +474,7 @@ export default function ChargePointsPage() {
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{cp.lastHeartbeatAt ? (
|
{cp.lastHeartbeatAt ? (
|
||||||
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
|
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted">—</span>
|
<span className="text-muted">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Xmark,
|
Xmark,
|
||||||
} from "@gravity-ui/icons";
|
} from "@gravity-ui/icons";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
|
||||||
// ── Status maps (same as charge-points page) ────────────────────────────────
|
// ── Status maps (same as charge-points page) ────────────────────────────────
|
||||||
|
|
||||||
@@ -411,7 +412,7 @@ export default function ChargePage() {
|
|||||||
.map((cp) => {
|
.map((cp) => {
|
||||||
const online =
|
const online =
|
||||||
!!cp.lastHeartbeatAt &&
|
!!cp.lastHeartbeatAt &&
|
||||||
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
|
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||||
const availableCount = cp.connectors.filter(
|
const availableCount = cp.connectors.filter(
|
||||||
(c) => c.status === "Available",
|
(c) => c.status === "Available",
|
||||||
).length;
|
).length;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { parseDate } from "@internationalized/date";
|
|||||||
import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
|
import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
|
||||||
import { api, type IdTag, type UserRow } from "@/lib/api";
|
import { api, type IdTag, type UserRow } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
|
||||||
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
|
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
|
||||||
Accepted: "success",
|
Accepted: "success",
|
||||||
@@ -529,7 +530,7 @@ export default function IdTagsPage() {
|
|||||||
)}
|
)}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{tag.expiryDate ? (
|
{tag.expiryDate ? (
|
||||||
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
|
dayjs(tag.expiryDate).format("YYYY/M/D")
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted">—</span>
|
<span className="text-muted">—</span>
|
||||||
)}
|
)}
|
||||||
@@ -538,7 +539,7 @@ export default function IdTagsPage() {
|
|||||||
{tag.parentIdTag ?? <span className="text-muted">—</span>}
|
{tag.parentIdTag ?? <span className="text-muted">—</span>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell className="text-sm">
|
<Table.Cell className="text-sm">
|
||||||
{new Date(tag.createdAt).toLocaleString("zh-CN")}
|
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button, Card, Spinner } from "@heroui/react";
|
import { Button, Card, Spinner } from "@heroui/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -13,23 +14,29 @@ import {
|
|||||||
ArrowRotateRight,
|
ArrowRotateRight,
|
||||||
TriangleExclamation,
|
TriangleExclamation,
|
||||||
} from "@gravity-ui/icons";
|
} from "@gravity-ui/icons";
|
||||||
|
import { AreaChart } from "@tremor/react";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
import { useSession } from "@/lib/auth-client";
|
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 ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function timeAgo(dateStr: string | null | undefined): string {
|
function timeAgo(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
const ms = Date.now() - new Date(dateStr).getTime();
|
return dayjs(dateStr).fromNow();
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cpOnline(cp: ChargePoint): boolean {
|
function cpOnline(cp: ChargePoint): boolean {
|
||||||
if (!cp.lastHeartbeatAt) return false;
|
if (!cp.lastHeartbeatAt) return false;
|
||||||
return Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
|
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── StatCard ───────────────────────────────────────────────────────────────
|
// ── 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 ────────────────────────────────────────────────────
|
// ── RecentTransactions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
||||||
@@ -369,6 +558,9 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trend chart */}
|
||||||
|
{isAdmin && <TrendChart />}
|
||||||
|
|
||||||
{/* Detail panels */}
|
{/* Detail panels */}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { Alert, Button, CloseButton, Input, Label, Spinner, TextField, toast } from "@heroui/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 { Fingerprint, Lock, Pencil, Person, TrashBin, Xmark, Check } from "@gravity-ui/icons";
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
|
||||||
type Passkey = {
|
type Passkey = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -411,11 +412,7 @@ export default function SettingsPage() {
|
|||||||
{/* Date row: always visible */}
|
{/* Date row: always visible */}
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
添加于{" "}
|
添加于{" "}
|
||||||
{new Date(pk.createdAt).toLocaleString("zh-CN", {
|
{dayjs(pk.createdAt).format("YYYY年M月D日")}
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
|
|||||||
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
|
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
|
||||||
const LIMIT = 15;
|
const LIMIT = 15;
|
||||||
|
|
||||||
function formatDuration(start: string, stop: string | null): string {
|
function formatDuration(start: string, stop: string | null): string {
|
||||||
if (!stop) return "进行中";
|
if (!stop) return "进行中";
|
||||||
const ms = new Date(stop).getTime() - new Date(start).getTime();
|
const min = dayjs(stop).diff(dayjs(start), "minute");
|
||||||
const min = Math.floor(ms / 60000);
|
|
||||||
if (min < 60) return `${min} 分钟`;
|
if (min < 60) return `${min} 分钟`;
|
||||||
const h = Math.floor(min / 60);
|
const h = Math.floor(min / 60);
|
||||||
const m = 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>
|
<p className="mt-0.5 text-sm text-muted">共 {data?.total ?? "—"} 条</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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" : ""}`} />
|
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
|
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
|
||||||
{(["all", "active", "completed"] as const).map((s) => (
|
{(["all", "active", "completed"] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => handleStatusChange(s)}
|
onClick={() => handleStatusChange(s)}
|
||||||
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
|
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
status === s
|
status === s
|
||||||
? "bg-surface text-foreground shadow-sm"
|
? "bg-surface text-foreground shadow-sm"
|
||||||
: "text-muted hover:text-foreground"
|
: "text-muted hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
|
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
|
||||||
</button>
|
</button>
|
||||||
))} </div> </div>
|
))}{" "}
|
||||||
|
</div>{" "}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
@@ -138,7 +147,7 @@ export default function TransactionsPage() {
|
|||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell className="whitespace-nowrap text-sm">
|
<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>
|
||||||
<Table.Cell className="text-sm">
|
<Table.Cell className="text-sm">
|
||||||
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
|
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { CreditCard, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
|
import { CreditCard, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
|
||||||
import { api, type IdTag, type UserRow } from "@/lib/api";
|
import { api, type IdTag, type UserRow } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import dayjs from "@/lib/dayjs";
|
||||||
|
|
||||||
type CreateForm = {
|
type CreateForm = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -452,7 +453,7 @@ export default function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell className="text-sm">
|
<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>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@heroui/styles";
|
@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
|
* HeroUI Theme Customization
|
||||||
@@ -17,26 +20,26 @@
|
|||||||
--accent: oklch(62.04% 0.1951 253.83);
|
--accent: oklch(62.04% 0.1951 253.83);
|
||||||
--accent-foreground: oklch(99.11% 0 0);
|
--accent-foreground: oklch(99.11% 0 0);
|
||||||
--background: oklch(97.02% 0.0069 253.83);
|
--background: oklch(97.02% 0.0069 253.83);
|
||||||
--border: oklch(90.00% 0.0069 253.83);
|
--border: oklch(90% 0.0069 253.83);
|
||||||
--danger: oklch(65.32% 0.2360 25.74);
|
--danger: oklch(65.32% 0.236 25.74);
|
||||||
--danger-foreground: oklch(99.11% 0 0);
|
--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);
|
--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-foreground: oklch(21.03% 0.0069 253.83);
|
||||||
--field-placeholder: oklch(55.17% 0.0138 253.83);
|
--field-placeholder: oklch(55.17% 0.0138 253.83);
|
||||||
--focus: oklch(62.04% 0.1951 253.83);
|
--focus: oklch(62.04% 0.1951 253.83);
|
||||||
--foreground: oklch(21.03% 0.0069 253.83);
|
--foreground: oklch(21.03% 0.0069 253.83);
|
||||||
--muted: oklch(55.17% 0.0138 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);
|
--overlay-foreground: oklch(21.03% 0.0069 253.83);
|
||||||
--scrollbar: oklch(87.10% 0.0069 253.83);
|
--scrollbar: oklch(87.1% 0.0069 253.83);
|
||||||
--segment: oklch(100.00% 0.0069 253.83);
|
--segment: oklch(100% 0.0069 253.83);
|
||||||
--segment-foreground: oklch(21.03% 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: oklch(73.29% 0.1962 150.81);
|
||||||
--success-foreground: oklch(21.03% 0.0059 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-foreground: oklch(21.03% 0.0069 253.83);
|
||||||
--surface-secondary: oklch(95.24% 0.0055 253.83);
|
--surface-secondary: oklch(95.24% 0.0055 253.83);
|
||||||
--surface-secondary-foreground: oklch(21.03% 0.0069 253.83);
|
--surface-secondary-foreground: oklch(21.03% 0.0069 253.83);
|
||||||
@@ -60,29 +63,29 @@
|
|||||||
/* Theme Colors (Dark Mode) */
|
/* Theme Colors (Dark Mode) */
|
||||||
--accent: oklch(62.04% 0.1951 253.83);
|
--accent: oklch(62.04% 0.1951 253.83);
|
||||||
--accent-foreground: oklch(99.11% 0 0);
|
--accent-foreground: oklch(99.11% 0 0);
|
||||||
--background: oklch(12.00% 0.0069 253.83);
|
--background: oklch(12% 0.0069 253.83);
|
||||||
--border: oklch(28.00% 0.0069 253.83);
|
--border: oklch(28% 0.0069 253.83);
|
||||||
--danger: oklch(59.40% 0.1994 24.63);
|
--danger: oklch(59.4% 0.1994 24.63);
|
||||||
--danger-foreground: oklch(99.11% 0 0);
|
--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);
|
--default-foreground: oklch(99.11% 0 0);
|
||||||
--field-background: oklch(21.03% 0.0138 253.83);
|
--field-background: oklch(21.03% 0.0138 253.83);
|
||||||
--field-foreground: oklch(99.11% 0.0069 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);
|
--focus: oklch(62.04% 0.1951 253.83);
|
||||||
--foreground: oklch(99.11% 0.0069 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: oklch(21.03% 0.0138 253.83);
|
||||||
--overlay-foreground: oklch(99.11% 0.0069 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: oklch(39.64% 0.0069 253.83);
|
||||||
--segment-foreground: oklch(99.11% 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: oklch(73.29% 0.1962 150.81);
|
||||||
--success-foreground: oklch(21.03% 0.0059 150.81);
|
--success-foreground: oklch(21.03% 0.0059 150.81);
|
||||||
--surface: oklch(21.03% 0.0138 253.83);
|
--surface: oklch(21.03% 0.0138 253.83);
|
||||||
--surface-foreground: oklch(99.11% 0.0069 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-secondary-foreground: oklch(99.11% 0.0069 253.83);
|
||||||
--surface-tertiary: oklch(27.21% 0.0103 253.83);
|
--surface-tertiary: oklch(27.21% 0.0103 253.83);
|
||||||
--surface-tertiary-foreground: oklch(99.11% 0.0069 253.83);
|
--surface-tertiary-foreground: oklch(99.11% 0.0069 253.83);
|
||||||
|
|||||||
@@ -154,9 +154,21 @@ export type PaginatedTransactions = {
|
|||||||
|
|
||||||
// ── API functions ──────────────────────────────────────────────────────────
|
// ── API functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ChartRange = "30d" | "7d" | "24h";
|
||||||
|
|
||||||
|
export type ChartDataPoint = {
|
||||||
|
bucket: string;
|
||||||
|
energyKwh: number;
|
||||||
|
revenue: number;
|
||||||
|
transactions: number;
|
||||||
|
utilizationPct: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
stats: {
|
stats: {
|
||||||
get: () => apiFetch<Stats | UserStats>("/api/stats"),
|
get: () => apiFetch<Stats | UserStats>("/api/stats"),
|
||||||
|
chart: (range: ChartRange) =>
|
||||||
|
apiFetch<ChartDataPoint[]>(`/api/stats/chart?range=${range}`),
|
||||||
},
|
},
|
||||||
chargePoints: {
|
chargePoints: {
|
||||||
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
||||||
|
|||||||
8
apps/web/lib/dayjs.ts
Normal file
8
apps/web/lib/dayjs.ts
Normal 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;
|
||||||
@@ -13,8 +13,10 @@
|
|||||||
"@heroui/styles": "3.0.0-beta.8",
|
"@heroui/styles": "3.0.0-beta.8",
|
||||||
"@internationalized/date": "^3.12.0",
|
"@internationalized/date": "^3.12.0",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
|
"@tremor/react": "4.0.0-beta-tremor-v4.4",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
|
"dayjs": "catalog:",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ catalog:
|
|||||||
"@tanstack/react-query": ^5.90.21
|
"@tanstack/react-query": ^5.90.21
|
||||||
"@better-auth/passkey": "^1.5.4"
|
"@better-auth/passkey": "^1.5.4"
|
||||||
"better-auth": "^1.5.4"
|
"better-auth": "^1.5.4"
|
||||||
|
"dayjs": "^1.11.19"
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- sharp
|
- sharp
|
||||||
|
|||||||
Reference in New Issue
Block a user