feat(dashboard): add transactions and users management pages with CRUD functionality

feat(auth): implement login page and authentication middleware
feat(sidebar): create sidebar component with user info and navigation links
feat(api): establish API client for interacting with backend services
This commit is contained in:
2026-03-10 15:17:32 +08:00
parent 9a2668fae5
commit 2cb89c74b3
32 changed files with 4648 additions and 83 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,13 @@
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.6", "@hono/node-server": "^1.19.6",
"@hono/node-ws": "^1.2.0", "@hono/node-ws": "^1.2.0",
"@hono/zod-validator": "^0.7.6",
"better-auth": "^1.3.34", "better-auth": "^1.3.34",
"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",
"pg": "^8.16.3" "pg": "^8.16.3",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@better-auth/cli": "^1.3.34", "@better-auth/cli": "^1.3.34",

View File

@@ -66,6 +66,12 @@ export const chargePoint = pgTable('charge_point', {
lastBootNotificationAt: timestamp('last_boot_notification_at', { lastBootNotificationAt: timestamp('last_boot_notification_at', {
withTimezone: true, withTimezone: true,
}), }),
/**
* 电价(单位:分/kWh即 0.01 CNY/kWh
* 交易结束时按实际用电量从储值卡扣费fee = ceil(energyWh * feePerKwh / 1000)
* 默认为 0即不计费
*/
feePerKwh: integer('fee_per_kwh').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp('created_at', { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
@@ -271,6 +277,11 @@ export const idTag = pgTable('id_tag', {
* 允许将 RFID 卡与注册用户绑定,支持 Web/App 远程查询充电记录 * 允许将 RFID 卡与注册用户绑定,支持 Web/App 远程查询充电记录
*/ */
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
/**
* 储值卡余额(单位:分)
* 以整数存储1 分 = 0.01 CNY前端显示时除以 100
*/
balance: integer('balance').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp('created_at', { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
@@ -361,6 +372,12 @@ export const transaction = pgTable(
'DeAuthorized', 'DeAuthorized',
], ],
}), }),
/**
* 本次充电扣费金额(单位:分)
* 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入
* null 表示未计费(如免费充电桩或交易异常终止)
*/
chargeAmount: integer('charge_amount'),
/** /**
* 关联的预约 ID若本次充电由预约触发 * 关联的预约 ID若本次充电由预约触发
* StartTransaction.req.reservationIdoptional * StartTransaction.req.reservationIdoptional

View File

@@ -8,13 +8,15 @@ import { logger } from 'hono/logger'
import { showRoutes } from 'hono/dev' 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 chargePointRoutes from './routes/charge-points.ts'
import transactionRoutes from './routes/transactions.ts'
import idTagRoutes from './routes/id-tags.ts'
import userRoutes from './routes/users.ts'
const app = new Hono<{ import type { HonoEnv } from './types/hono.ts'
Variables: {
user: typeof auth.$Infer.Session.user | null const app = new Hono<HonoEnv>()
session: typeof auth.$Infer.Session.session | null
}
}>()
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
@@ -34,10 +36,10 @@ app.use('*', async (c, next) => {
}) })
app.use( app.use(
'/api/auth/*', '/api/*',
cors({ cors({
origin: '*', origin: process.env.WEB_ORIGIN ?? '*',
allowMethods: ['GET', 'POST', 'OPTIONS'], allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'], allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length'], exposeHeaders: ['Content-Length'],
credentials: true, credentials: true,
@@ -46,6 +48,13 @@ app.use(
app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)) app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
// REST API routes
app.route('/api/stats', statsRoutes)
app.route('/api/charge-points', chargePointRoutes)
app.route('/api/transactions', transactionRoutes)
app.route('/api/id-tags', idTagRoutes)
app.route('/api/users', userRoutes)
app.get('/api', (c) => { app.get('/api', (c) => {
const user = c.get('user') const user = c.get('user')
const session = c.get('session') const session = c.get('session')

View File

@@ -2,7 +2,7 @@ import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { useDrizzle } from './db.js' import { useDrizzle } from './db.js'
import * as schema from '@/db/schema.ts' import * as schema from '@/db/schema.ts'
import { admin, bearer, jwt, username } from 'better-auth/plugins' import { admin, bearer, username } from 'better-auth/plugins'
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(useDrizzle(), { database: drizzleAdapter(useDrizzle(), {
@@ -11,13 +11,16 @@ export const auth = betterAuth({
...schema, ...schema,
}, },
}), }),
trustedOrigins: [
process.env.WEB_ORIGIN ?? 'http://localhost:3000',
],
user: { user: {
additionalFields: {}, additionalFields: {},
}, },
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
plugins: [admin(), username(), bearer(), jwt()], plugins: [admin(), username(), bearer()],
advanced: { advanced: {
cookiePrefix: 'helios_auth', cookiePrefix: 'helios_auth',
}, },

View File

@@ -0,0 +1,51 @@
import { eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import type {
AuthorizeRequest,
AuthorizeResponse,
IdTagInfo,
OcppConnectionContext,
} from "../types.ts";
/**
* Shared helper — resolves idTagInfo from the database.
* Used by Authorize, StartTransaction, and StopTransaction.
*
* @param checkBalance When true (default), rejects tags with balance ≤ 0.
* Pass false for StopTransaction where charging has already occurred.
*/
export async function resolveIdTagInfo(
idTagValue: string,
checkBalance = true,
): Promise<IdTagInfo> {
const db = useDrizzle();
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1);
if (!tag) return { status: "Invalid" };
if (tag.status === "Blocked") return { status: "Blocked" };
if (tag.expiryDate && tag.expiryDate < new Date()) {
return { status: "Expired", expiryDate: tag.expiryDate.toISOString() };
}
if (tag.status !== "Accepted") {
return { status: tag.status as IdTagInfo["status"] };
}
// Reject if balance is zero or negative
if (checkBalance && tag.balance <= 0) {
return { status: "Blocked" };
}
return {
status: "Accepted",
expiryDate: tag.expiryDate?.toISOString(),
parentIdTag: tag.parentIdTag ?? undefined,
};
}
export async function handleAuthorize(
payload: AuthorizeRequest,
_ctx: OcppConnectionContext,
): Promise<AuthorizeResponse> {
const idTagInfo = await resolveIdTagInfo(payload.idTag);
console.log(`[OCPP] Authorize idTag=${payload.idTag} -> ${idTagInfo.status}`);
return { idTagInfo };
}

View File

@@ -0,0 +1,51 @@
import { and, eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, meterValue } from "@/db/schema.js";
import type { MeterValuesRequest, MeterValuesResponse, OcppConnectionContext } from "../types.ts";
export async function handleMeterValues(
payload: MeterValuesRequest,
ctx: OcppConnectionContext,
): Promise<MeterValuesResponse> {
const db = useDrizzle();
const [cp] = await db
.select({ id: chargePoint.id })
.from(chargePoint)
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
.limit(1);
if (!cp) throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`);
const [conn] = await db
.select({ id: connector.id })
.from(connector)
.where(and(eq(connector.chargePointId, cp.id), eq(connector.connectorId, payload.connectorId)))
.limit(1);
if (!conn) {
console.warn(
`[OCPP] MeterValues: connector ${payload.connectorId} not found for ${ctx.chargePointIdentifier}`,
);
return {};
}
const records = payload.meterValue
.filter((mv) => mv.sampledValue?.length)
.map((mv) => ({
id: crypto.randomUUID(),
transactionId: payload.transactionId ?? null,
connectorId: conn.id,
chargePointId: cp.id,
connectorNumber: payload.connectorId,
timestamp: new Date(mv.timestamp),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
}));
if (records.length) {
await db.insert(meterValue).values(records);
}
return {};
}

View File

@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, transaction } from "@/db/schema.js";
import type {
StartTransactionRequest,
StartTransactionResponse,
OcppConnectionContext,
} from "../types.ts";
import { resolveIdTagInfo } from "./authorize.ts";
export async function handleStartTransaction(
payload: StartTransactionRequest,
ctx: OcppConnectionContext,
): Promise<StartTransactionResponse> {
const db = useDrizzle();
// Resolve idTag authorization
const idTagInfo = await resolveIdTagInfo(payload.idTag);
// Find charge point
const [cp] = await db
.select({ id: chargePoint.id })
.from(chargePoint)
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
.limit(1);
if (!cp) throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`);
// Upsert connector — it may not exist yet if StatusNotification was skipped
const [conn] = await db
.insert(connector)
.values({
id: crypto.randomUUID(),
chargePointId: cp.id,
connectorId: payload.connectorId,
status: "Charging",
errorCode: "NoError",
lastStatusAt: new Date(),
})
.onConflictDoUpdate({
target: [connector.chargePointId, connector.connectorId],
set: { status: "Charging", updatedAt: new Date() },
})
.returning({ id: connector.id });
const rejected = idTagInfo.status !== "Accepted";
const now = new Date();
// Insert transaction record regardless of auth status (OCPP spec requirement)
const [tx] = await db
.insert(transaction)
.values({
chargePointId: cp.id,
connectorId: conn.id,
connectorNumber: payload.connectorId,
idTag: payload.idTag,
idTagStatus: idTagInfo.status,
startTimestamp: new Date(payload.timestamp),
startMeterValue: payload.meterStart,
reservationId: payload.reservationId ?? null,
// If rejected, immediately close the transaction so it doesn't appear as in-progress
...(rejected && {
stopTimestamp: now,
stopMeterValue: payload.meterStart,
chargeAmount: 0,
stopReason: "DeAuthorized",
}),
})
.returning({ id: transaction.id });
// If rejected, reset connector back to Available
if (rejected) {
await db
.update(connector)
.set({ status: "Available", updatedAt: now })
.where(eq(connector.id, conn.id));
}
console.log(
`[OCPP] StartTransaction cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} ` +
`idTag=${payload.idTag} status=${idTagInfo.status} txId=${tx.id}`,
);
return { transactionId: tx.id, idTagInfo };
}

View File

@@ -1,4 +1,4 @@
import { eq, and } from 'drizzle-orm' import { eq } from 'drizzle-orm'
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 {

View File

@@ -0,0 +1,101 @@
import { eq, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
import type {
StopTransactionRequest,
StopTransactionResponse,
OcppConnectionContext,
} from "../types.ts";
import { resolveIdTagInfo } from "./authorize.ts";
export async function handleStopTransaction(
payload: StopTransactionRequest,
_ctx: OcppConnectionContext,
): Promise<StopTransactionResponse> {
const db = useDrizzle();
// Update the transaction record
const [tx] = await db
.update(transaction)
.set({
stopTimestamp: new Date(payload.timestamp),
stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
updatedAt: new Date(),
})
.where(eq(transaction.id, payload.transactionId))
.returning();
if (!tx) {
console.warn(`[OCPP] StopTransaction: transaction ${payload.transactionId} not found`);
return {};
}
// Set connector back to Available
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.where(eq(connector.id, tx.connectorId));
// Store embedded meter values (transactionData)
if (payload.transactionData?.length) {
const records = payload.transactionData.flatMap((mv) =>
mv.sampledValue?.length
? [
{
id: crypto.randomUUID(),
transactionId: tx.id,
connectorId: tx.connectorId,
chargePointId: tx.chargePointId,
connectorNumber: tx.connectorNumber,
timestamp: new Date(mv.timestamp),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
},
]
: [],
);
if (records.length) {
await db.insert(meterValue).values(records);
}
}
const energyWh = payload.meterStop - tx.startMeterValue;
// Deduct balance from the idTag based on actual energy consumed
const [cp] = await db
.select({ feePerKwh: chargePoint.feePerKwh })
.from(chargePoint)
.where(eq(chargePoint.id, tx.chargePointId))
.limit(1);
const feeFen =
cp && cp.feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * cp.feePerKwh) / 1000) : 0;
// Always record the charge amount (0 if free)
await db
.update(transaction)
.set({ chargeAmount: feeFen, updatedAt: new Date() })
.where(eq(transaction.id, tx.id));
if (feeFen > 0) {
await db
.update(idTag)
.set({
balance: sql`${idTag.balance} - ${feeFen}`,
updatedAt: new Date(),
})
.where(eq(idTag.idTag, tx.idTag));
}
console.log(
`[OCPP] StopTransaction txId=${payload.transactionId} ` +
`reason=${payload.reason ?? "none"} energyWh=${energyWh} feeFen=${feeFen}`,
);
// Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred)
const idTagInfo = payload.idTag ? await resolveIdTagInfo(payload.idTag, false) : undefined;
return { idTagInfo };
}

View File

@@ -6,19 +6,41 @@ import {
type OcppErrorCode, type OcppErrorCode,
type OcppMessage, type OcppMessage,
type OcppConnectionContext, type OcppConnectionContext,
type AuthorizeRequest,
type AuthorizeResponse,
type BootNotificationRequest, type BootNotificationRequest,
type BootNotificationResponse, type BootNotificationResponse,
type HeartbeatRequest, type HeartbeatRequest,
type HeartbeatResponse, type HeartbeatResponse,
type MeterValuesRequest,
type MeterValuesResponse,
type StartTransactionRequest,
type StartTransactionResponse,
type StatusNotificationRequest, type StatusNotificationRequest,
type StatusNotificationResponse, type StatusNotificationResponse,
type StopTransactionRequest,
type StopTransactionResponse,
} from './types.ts' } from './types.ts'
/**
* Global registry of active OCPP WebSocket connections.
* Key = chargePointIdentifier, Value = WSContext
*/
export const ocppConnections = new Map<string, WSContext>()
import { handleAuthorize } from './actions/authorize.ts'
import { handleBootNotification } from './actions/boot-notification.ts' import { handleBootNotification } from './actions/boot-notification.ts'
import { handleHeartbeat } from './actions/heartbeat.ts' import { handleHeartbeat } from './actions/heartbeat.ts'
import { handleMeterValues } from './actions/meter-values.ts'
import { handleStartTransaction } from './actions/start-transaction.ts'
import { handleStatusNotification } from './actions/status-notification.ts' import { handleStatusNotification } from './actions/status-notification.ts'
import { handleStopTransaction } from './actions/stop-transaction.ts'
// Typed dispatch map — only registered actions are accepted // Typed dispatch map — only registered actions are accepted
type ActionHandlerMap = { type ActionHandlerMap = {
Authorize: (
payload: AuthorizeRequest,
ctx: OcppConnectionContext,
) => Promise<AuthorizeResponse>
BootNotification: ( BootNotification: (
payload: BootNotificationRequest, payload: BootNotificationRequest,
ctx: OcppConnectionContext, ctx: OcppConnectionContext,
@@ -27,16 +49,32 @@ type ActionHandlerMap = {
payload: HeartbeatRequest, payload: HeartbeatRequest,
ctx: OcppConnectionContext, ctx: OcppConnectionContext,
) => Promise<HeartbeatResponse> ) => Promise<HeartbeatResponse>
MeterValues: (
payload: MeterValuesRequest,
ctx: OcppConnectionContext,
) => Promise<MeterValuesResponse>
StartTransaction: (
payload: StartTransactionRequest,
ctx: OcppConnectionContext,
) => Promise<StartTransactionResponse>
StatusNotification: ( StatusNotification: (
payload: StatusNotificationRequest, payload: StatusNotificationRequest,
ctx: OcppConnectionContext, ctx: OcppConnectionContext,
) => Promise<StatusNotificationResponse> ) => Promise<StatusNotificationResponse>
StopTransaction: (
payload: StopTransactionRequest,
ctx: OcppConnectionContext,
) => Promise<StopTransactionResponse>
} }
const actionHandlers: ActionHandlerMap = { const actionHandlers: ActionHandlerMap = {
Authorize: handleAuthorize,
BootNotification: handleBootNotification, BootNotification: handleBootNotification,
Heartbeat: handleHeartbeat, Heartbeat: handleHeartbeat,
MeterValues: handleMeterValues,
StartTransaction: handleStartTransaction,
StatusNotification: handleStatusNotification, StatusNotification: handleStatusNotification,
StopTransaction: handleStopTransaction,
} }
function sendCallResult(ws: WSContext, uniqueId: string, payload: unknown): void { function sendCallResult(ws: WSContext, uniqueId: string, payload: unknown): void {
@@ -74,6 +112,7 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
ws.close(1002, 'Unsupported subprotocol') ws.close(1002, 'Unsupported subprotocol')
return return
} }
ocppConnections.set(chargePointIdentifier, ws)
console.log( console.log(
`[OCPP] ${chargePointIdentifier} connected` + `[OCPP] ${chargePointIdentifier} connected` +
(remoteAddr ? ` from ${remoteAddr}` : ''), (remoteAddr ? ` from ${remoteAddr}` : ''),
@@ -136,6 +175,7 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
}, },
onClose(evt: CloseEvent, _ws: WSContext) { onClose(evt: CloseEvent, _ws: WSContext) {
ocppConnections.delete(chargePointIdentifier)
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`) console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
}, },
} }

View File

@@ -82,10 +82,64 @@ export type AuthorizeRequest = {
idTag: string // CiString20Type idTag: string // CiString20Type
} }
export type AuthorizeResponse = { export type IdTagInfo = {
idTagInfo: {
status: 'Accepted' | 'Blocked' | 'Expired' | 'Invalid' | 'ConcurrentTx' status: 'Accepted' | 'Blocked' | 'Expired' | 'Invalid' | 'ConcurrentTx'
expiryDate?: string expiryDate?: string
parentIdTag?: string parentIdTag?: string
}
} }
export type AuthorizeResponse = {
idTagInfo: IdTagInfo
}
// Section 4.3 StartTransaction
export type StartTransactionRequest = {
connectorId: number // ≥1
idTag: string // CiString20Type
meterStart: number // Wh
timestamp: string // UTC ISO 8601
reservationId?: number
}
export type StartTransactionResponse = {
transactionId: number
idTagInfo: IdTagInfo
}
// Section 4.4 StopTransaction
export type StopTransactionRequest = {
transactionId: number
meterStop: number // Wh
timestamp: string // UTC ISO 8601
reason?: string
idTag?: string // CiString20Type, optional (staff override)
transactionData?: MeterValue[]
}
export type StopTransactionResponse = {
idTagInfo?: IdTagInfo
}
// Section 4.7 MeterValues
export type SampledValue = {
value: string
context?: string
format?: string
measurand?: string
phase?: string
location?: string
unit?: string
}
export type MeterValue = {
timestamp: string
sampledValue: SampledValue[]
}
export type MeterValuesRequest = {
connectorId: number
transactionId?: number
meterValue: MeterValue[]
}
export type MeterValuesResponse = Record<string, never>

View File

@@ -0,0 +1,92 @@
import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js";
const app = new Hono();
/** GET /api/charge-points — list all charge points with connectors */
app.get("/", async (c) => {
const db = useDrizzle();
const cps = await db.select().from(chargePoint).orderBy(desc(chargePoint.createdAt));
// Attach connectors (connectorId > 0 only, excludes the main-controller row)
const connectors = cps.length
? await db
.select()
.from(connector)
.where(
sql`${connector.chargePointId} = any(${sql.raw(`array[${cps.map((cp) => `'${cp.id}'`).join(",")}]`)}) and ${connector.connectorId} > 0`,
)
: [];
const connectorsByCP: Record<string, typeof connectors> = {};
for (const conn of connectors) {
if (!connectorsByCP[conn.chargePointId]) connectorsByCP[conn.chargePointId] = [];
connectorsByCP[conn.chargePointId].push(conn);
}
return c.json(
cps.map((cp) => ({
...cp,
connectors: connectorsByCP[cp.id] ?? [],
})),
);
});
/** GET /api/charge-points/:id — single charge point */
app.get("/:id", async (c) => {
const db = useDrizzle();
const id = c.req.param("id");
const [cp] = await db.select().from(chargePoint).where(eq(chargePoint.id, id)).limit(1);
if (!cp) return c.json({ error: "Not found" }, 404);
const connectors = await db.select().from(connector).where(eq(connector.chargePointId, id));
return c.json({ ...cp, connectors });
});
/** PATCH /api/charge-points/:id — update feePerKwh */
app.patch("/:id", async (c) => {
const db = useDrizzle();
const id = c.req.param("id");
const body = await c.req.json<{ feePerKwh?: number }>();
if (
typeof body.feePerKwh !== "number" ||
body.feePerKwh < 0 ||
!Number.isInteger(body.feePerKwh)
) {
return c.json({ error: "feePerKwh must be a non-negative integer (unit: fen/kWh)" }, 400);
}
const [updated] = await db
.update(chargePoint)
.set({ feePerKwh: body.feePerKwh, updatedAt: new Date() })
.where(eq(chargePoint.id, id))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json({ feePerKwh: updated.feePerKwh });
});
/** DELETE /api/charge-points/:id — delete a charge point (cascades to connectors, transactions, meter values) */
app.delete("/:id", async (c) => {
const db = useDrizzle();
const id = c.req.param("id");
const [deleted] = await db
.delete(chargePoint)
.where(eq(chargePoint.id, id))
.returning({ id: chargePoint.id });
if (!deleted) return c.json({ error: "Not found" }, 404);
return c.json({ success: true });
});
export default app;

View File

@@ -0,0 +1,85 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const app = new Hono();
const idTagSchema = z.object({
idTag: z.string().min(1).max(20),
parentIdTag: z.string().max(20).optional().nullable(),
status: z.enum(["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"]).default("Accepted"),
expiryDate: z.string().date().optional().nullable(),
userId: z.string().optional().nullable(),
balance: z.number().int().min(0).default(0),
});
const idTagUpdateSchema = idTagSchema.partial().omit({ idTag: true });
/** GET /api/id-tags */
app.get("/", async (c) => {
const db = useDrizzle();
const tags = await db.select().from(idTag).orderBy(desc(idTag.createdAt));
return c.json(tags);
});
/** GET /api/id-tags/:id */
app.get("/:id", async (c) => {
const db = useDrizzle();
const tagId = c.req.param("id");
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, tagId)).limit(1);
if (!tag) return c.json({ error: "Not found" }, 404);
return c.json(tag);
});
/** POST /api/id-tags */
app.post("/", zValidator("json", idTagSchema), async (c) => {
const db = useDrizzle();
const body = c.req.valid("json");
const [created] = await db
.insert(idTag)
.values({
...body,
expiryDate: body.expiryDate ? new Date(body.expiryDate) : null,
})
.returning();
return c.json(created, 201);
});
/** PATCH /api/id-tags/:id */
app.patch("/:id", zValidator("json", idTagUpdateSchema), async (c) => {
const db = useDrizzle();
const tagId = c.req.param("id");
const body = c.req.valid("json");
const [updated] = await db
.update(idTag)
.set({
...body,
expiryDate: body.expiryDate ? new Date(body.expiryDate) : undefined,
updatedAt: new Date(),
})
.where(eq(idTag.idTag, tagId))
.returning();
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json(updated);
});
/** DELETE /api/id-tags/:id */
app.delete("/:id", async (c) => {
const db = useDrizzle();
const tagId = c.req.param("id");
const [deleted] = await db
.delete(idTag)
.where(eq(idTag.idTag, tagId))
.returning({ idTag: idTag.idTag });
if (!deleted) return c.json({ error: "Not found" }, 404);
return c.json({ success: true });
});
export default app;

View File

@@ -0,0 +1,45 @@
import { Hono } from "hono";
import { isNull, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, transaction, idTag } from "@/db/schema.js";
const app = new Hono();
app.get("/", async (c) => {
const db = useDrizzle();
const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] =
await Promise.all([
// Total charge points
db.select({ count: sql<number>`count(*)::int` }).from(chargePoint),
// Online charge points (received heartbeat in last 2×heartbeat interval, default 120s)
db
.select({ count: sql<number>`count(*)::int` })
.from(chargePoint)
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
// Active (in-progress) transactions
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.where(isNull(transaction.stopTimestamp)),
// Total id tags
db.select({ count: sql<number>`count(*)::int` }).from(idTag),
// Energy dispensed today (sum of stopMeterValue - startMeterValue for transactions ending today)
db
.select({
total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
})
.from(transaction)
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
]);
return c.json({
totalChargePoints: totalChargePoints[0].count,
onlineChargePoints: onlineChargePoints[0].count,
activeTransactions: activeTransactions[0].count,
totalIdTags: totalIdTags[0].count,
todayEnergyWh: todayEnergy[0].total,
});
});
export default app;

View File

@@ -0,0 +1,194 @@
import { Hono } from "hono";
import { desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
const app = new Hono();
/** GET /api/transactions?page=1&limit=20&status=active|completed */
app.get("/", async (c) => {
const page = Math.max(1, Number(c.req.query("page") ?? 1));
const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") ?? 20)));
const status = c.req.query("status"); // 'active' | 'completed'
const offset = (page - 1) * limit;
const db = useDrizzle();
const whereClause =
status === "active"
? isNull(transaction.stopTimestamp)
: status === "completed"
? isNotNull(transaction.stopTimestamp)
: undefined;
const [{ total }] = await db
.select({ total: sql<number>`count(*)::int` })
.from(transaction)
.where(whereClause);
const rows = await db
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
connectorNumber: connector.connectorId,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
.leftJoin(connector, eq(transaction.connectorId, connector.id))
.where(whereClause)
.orderBy(desc(transaction.startTimestamp))
.limit(limit)
.offset(offset);
return c.json({
data: rows.map((r) => ({
...r.transaction,
chargePointIdentifier: r.chargePointIdentifier,
connectorNumber: r.connectorNumber,
energyWh:
r.transaction.stopMeterValue != null
? r.transaction.stopMeterValue - r.transaction.startMeterValue
: null,
})),
total,
page,
totalPages: Math.max(1, Math.ceil(total / limit)),
});
});
/** GET /api/transactions/:id */
app.get("/:id", async (c) => {
const db = useDrizzle();
const id = Number(c.req.param("id"));
const [row] = await db
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
.where(eq(transaction.id, id))
.limit(1);
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({
...row.transaction,
chargePointIdentifier: row.chargePointIdentifier,
energyWh:
row.transaction.stopMeterValue != null
? row.transaction.stopMeterValue - row.transaction.startMeterValue
: null,
});
});
/**
* POST /api/transactions/:id/stop
* Manually stop an active transaction.
* 1. If the charge point is connected, send OCPP RemoteStopTransaction.
* 2. In either case (online or offline), settle the transaction in the DB immediately
* so the record is always finalised from the admin side.
*/
app.post("/:id/stop", async (c) => {
const db = useDrizzle();
const id = Number(c.req.param("id"));
// Load the transaction
const [row] = await db
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
feePerKwh: chargePoint.feePerKwh,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
.where(eq(transaction.id, id))
.limit(1);
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();
// Try to send RemoteStopTransaction via OCPP if the charge point is online
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
if (ws) {
const uniqueId = crypto.randomUUID();
ws.send(
JSON.stringify([
OCPP_MESSAGE_TYPE.CALL,
uniqueId,
"RemoteStopTransaction",
{ transactionId: row.transaction.id },
]),
);
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
}
// Settle in DB regardless (charge point may be offline or slow to respond)
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
const stopMeterValue = row.transaction.startMeterValue;
const energyWh = 0; // cannot know actual energy without stop meter value
const feePerKwh = row.feePerKwh ?? 0;
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
const [updated] = await db
.update(transaction)
.set({
stopTimestamp: now,
stopMeterValue,
stopReason: "Remote",
chargeAmount: feeFen,
updatedAt: now,
})
.where(eq(transaction.id, id))
.returning();
if (feeFen > 0) {
await db
.update(idTag)
.set({
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
updatedAt: now,
})
.where(eq(idTag.idTag, row.transaction.idTag));
}
return c.json({
...updated,
chargePointIdentifier: row.chargePointIdentifier,
online: !!ws,
energyWh,
});
});
/** DELETE /api/transactions/:id — delete a transaction record */
app.delete("/:id", async (c) => {
const db = useDrizzle();
const id = Number(c.req.param("id"));
const [row] = await db
.select({ transaction, connectorId: transaction.connectorId })
.from(transaction)
.where(eq(transaction.id, id))
.limit(1);
if (!row) return c.json({ error: "Not found" }, 404);
// If the transaction is still active, reset the connector to Available
if (!row.transaction.stopTimestamp) {
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.where(eq(connector.id, row.transaction.connectorId));
}
await db.delete(transaction).where(eq(transaction.id, id));
return c.json({ success: true });
});
export default app;

View File

@@ -0,0 +1,76 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { user } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono<HonoEnv>();
const userUpdateSchema = z.object({
name: z.string().min(1).max(100).optional(),
username: z.string().min(1).max(50).optional().nullable(),
role: z.enum(["user", "admin"]).optional(),
banned: z.boolean().optional(),
banReason: z.string().max(200).optional().nullable(),
});
/** GET /api/users — admin only */
app.get("/", async (c) => {
const currentUser = c.get("user");
if (currentUser?.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
const db = useDrizzle();
const users = await db
.select({
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
username: user.username,
role: user.role,
banned: user.banned,
banReason: user.banReason,
createdAt: user.createdAt,
})
.from(user)
.orderBy(desc(user.createdAt));
return c.json(users);
});
/** PATCH /api/users/:id — update role/ban status, admin only */
app.patch("/:id", zValidator("json", userUpdateSchema), async (c) => {
const currentUser = c.get("user");
if (currentUser?.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
const db = useDrizzle();
const userId = c.req.param("id");
const body = c.req.valid("json");
const [updated] = await db
.update(user)
.set({
...body,
updatedAt: new Date(),
})
.where(eq(user.id, userId))
.returning({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
banned: user.banned,
banReason: user.banReason,
});
if (!updated) return c.json({ error: "Not found" }, 404);
return c.json(updated);
});
export default app;

View File

@@ -0,0 +1,8 @@
import type { auth } from "@/lib/auth.ts";
export type HonoEnv = {
Variables: {
user: typeof auth.$Infer.Session.user | null;
session: typeof auth.$Infer.Session.session | null;
};
};

View File

@@ -0,0 +1,275 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Chip, Input, Label, Modal, Spinner, Table, TextField } from "@heroui/react";
import { Pencil, TrashBin } from "@gravity-ui/icons";
import { api, type ChargePoint } from "@/lib/api";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Available: "success",
Charging: "success",
Occupied: "warning",
Reserved: "warning",
Faulted: "danger",
Unavailable: "danger",
Preparing: "warning",
Finishing: "warning",
SuspendedEV: "warning",
SuspendedEVSE: "warning",
};
const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Accepted: "success",
Pending: "warning",
Rejected: "danger",
};
export default function ChargePointsPage() {
const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]);
const [editTarget, setEditTarget] = useState<ChargePoint | null>(null);
const [feeInput, setFeeInput] = useState("0");
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false);
const hasFetched = useRef(false);
const load = useCallback(async () => {
const data = await api.chargePoints.list().catch(() => []);
setChargePoints(data);
}, []);
useEffect(() => {
if (!hasFetched.current) {
hasFetched.current = true;
load();
}
}, [load]);
const openEdit = (cp: ChargePoint) => {
setEditTarget(cp);
setFeeInput(String(cp.feePerKwh));
};
const handleSave = async () => {
if (!editTarget) return;
const fee = Math.max(0, Math.round(Number(feeInput) || 0));
setSaving(true);
try {
await api.chargePoints.update(String(editTarget.id), { feePerKwh: fee });
setChargePoints((prev) =>
prev.map((cp) => (cp.id === editTarget.id ? { ...cp, feePerKwh: fee } : cp)),
);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.chargePoints.delete(String(deleteTarget.id));
setChargePoints((prev) => prev.filter((cp) => cp.id !== deleteTarget.id));
setDeleteTarget(null);
} finally {
setDeleting(false);
}
};
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p>
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column> / </Table.Column>
<Table.Column></Table.Column>
<Table.Column>/kWh</Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column>{""}</Table.Column>
</Table.Header>
<Table.Body>
{chargePoints.length === 0 && (
<Table.Row id="empty">
<Table.Cell>
<span className="text-muted text-sm"></span>
</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
</Table.Row>
)}
{chargePoints.map((cp) => (
<Table.Row key={cp.id} id={String(cp.id)}>
<Table.Cell className="font-mono font-medium">
{cp.chargePointIdentifier}
</Table.Cell>
<Table.Cell>
{cp.chargePointVendor && cp.chargePointModel ? (
`${cp.chargePointVendor} / ${cp.chargePointModel}`
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm"
variant="soft"
>
{cp.registrationStatus}
</Chip>
</Table.Cell>
<Table.Cell className="tabular-nums">
{cp.feePerKwh > 0 ? (
<span>
{cp.feePerKwh}
<span className="ml-1 text-xs text-muted">
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
</span>
</span>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
{cp.lastHeartbeatAt ? (
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
<div className="flex flex-wrap gap-1">
{cp.connectors.length === 0 ? (
<span className="text-muted text-sm"></span>
) : (
cp.connectors.map((conn) => (
<Chip
key={conn.id}
color={statusColorMap[conn.status] ?? "warning"}
size="sm"
variant="soft"
>
#{conn.connectorId} {conn.status}
</Chip>
))
)}
</div>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1">
<Modal>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => openEdit(cp)}
>
<Pencil className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<p className="text-sm text-muted">
<span className="font-mono font-medium text-foreground">
{cp.chargePointIdentifier}
</span>
</p>
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={feeInput}
onChange={(e) => setFeeInput(e.target.value)}
/>
</TextField>
<p className="text-xs text-muted">
0 ¥
{((Number(feeInput) || 0) / 100).toFixed(2)}/kWh
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
<Modal>
<Button
isIconOnly
size="sm"
variant="danger-soft"
onPress={() => setDeleteTarget(cp)}
>
<TrashBin className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
{cp.chargePointIdentifier}
</span>
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deleting}
onPress={handleDelete}
>
{deleting ? <Spinner size="sm" /> : "确认删除"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
);
}

View File

@@ -0,0 +1,484 @@
"use client";
import { useEffect, useState } from "react";
import {
Autocomplete,
Button,
Calendar,
Chip,
DateField,
DatePicker,
EmptyState,
Input,
Label,
ListBox,
Modal,
SearchField,
Select,
Spinner,
Table,
TextField,
useFilter,
} from "@heroui/react";
import { parseDate } from "@internationalized/date";
import { Pencil, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Accepted: "success",
Blocked: "danger",
Expired: "warning",
Invalid: "danger",
ConcurrentTx: "warning",
};
type FormState = {
idTag: string;
status: string;
expiryDate: string;
parentIdTag: string;
userId: string;
balance: string;
};
const emptyForm: FormState = {
idTag: "",
status: "Accepted",
expiryDate: "",
parentIdTag: "",
userId: "",
balance: "0",
};
const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const;
/** 将「元」字符串转为分(整数),无效时返回 0 */
function yuanToFen(yuan: string): number {
const n = parseFloat(yuan);
return isNaN(n) ? 0 : Math.round(n * 100);
}
/** 将分(整数)格式化为「元」字符串 */
function fenToYuan(fen: number): string {
return (fen / 100).toFixed(2);
}
function UserAutocomplete({
userId,
onChange,
users,
}: {
userId: string;
onChange: (id: string) => void;
users: UserRow[];
}) {
const { contains } = useFilter({ sensitivity: "base" });
return (
<Autocomplete
fullWidth
placeholder="搜索用户…"
selectionMode="single"
value={userId || null}
onChange={(key) => onChange(key ? String(key) : "")}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
{({ isPlaceholder, state }: any) => {
if (isPlaceholder || !state.selectedItems?.length)
return <span className="text-muted"></span>;
const u = users.find((u) => u.id === state.selectedItems[0]?.key);
return u ? <span>{u.name ?? u.username ?? u.email}</span> : null;
}}
</Autocomplete.Value>
<Autocomplete.ClearButton />
<Autocomplete.Indicator />
</Autocomplete.Trigger>
<Autocomplete.Popover>
<Autocomplete.Filter filter={contains}>
<SearchField autoFocus name="userSearch" variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder="搜索姓名或邮箱…" />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<ListBox renderEmptyState={() => <EmptyState></EmptyState>}>
{users.map((u) => (
<ListBox.Item
key={u.id}
id={u.id}
textValue={`${u.name ?? u.username ?? ""} ${u.email}`}
>
<span className="font-medium">{u.name ?? u.username ?? u.email}</span>
<span className="ml-1.5 text-xs text-muted">{u.email}</span>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
function TagFormBody({
form,
setForm,
isEdit,
users,
}: {
form: FormState;
setForm: (f: FormState) => void;
isEdit: boolean;
users: UserRow[];
}) {
return (
<>
<TextField fullWidth>
<Label className="text-sm font-medium">{isEdit ? "卡号" : "卡号 (idTag)"}</Label>
<Input
disabled={isEdit}
className="font-mono"
placeholder="e.g. RFID001"
value={form.idTag}
onChange={(e) => setForm({ ...form, idTag: e.target.value })}
/>
</TextField>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={form.status}
onSelectionChange={(key) => setForm({ ...form, status: String(key) })}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{STATUS_OPTIONS.map((s) => (
<ListBox.Item key={s} id={s}>
{s}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">{isEdit ? "有效期" : "有效期 (可选)"}</Label>
<DatePicker
value={form.expiryDate ? parseDate(form.expiryDate) : null}
onChange={(date) => setForm({ ...form, expiryDate: date ? date.toString() : "" })}
>
<DateField.Group fullWidth>
<DateField.InputContainer>
<DateField.Input>
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
</DateField.InputContainer>
<DateField.Suffix>
<DatePicker.Trigger>
<DatePicker.TriggerIndicator />
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover>
<Calendar>
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>
{(date) => (
<Calendar.Cell date={date}>
{({ formattedDate }) => (
<>
{formattedDate}
<Calendar.CellIndicator />
</>
)}
</Calendar.Cell>
)}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar>
</DatePicker.Popover>
</DatePicker>
</div>
<TextField fullWidth>
<Label className="text-sm font-medium">{isEdit ? "父卡号" : "父卡号 (可选)"}</Label>
<Input
className="font-mono"
placeholder="parentIdTag"
value={form.parentIdTag}
onChange={(e) => setForm({ ...form, parentIdTag: e.target.value })}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={form.balance}
onChange={(e) => setForm({ ...form, balance: e.target.value })}
/>
</TextField>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<UserAutocomplete
userId={form.userId}
onChange={(id) => setForm({ ...form, userId: id })}
users={users}
/>
</div>
</>
);
}
export default function IdTagsPage() {
const [tags, setTags] = useState<IdTag[]>([]);
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<IdTag | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [deletingTag, setDeletingTag] = useState<string | null>(null);
const load = async () => {
setLoading(true);
try {
const [tagList, userList] = await Promise.all([
api.idTags.list(),
api.users.list().catch(() => [] as UserRow[]),
]);
setTags(tagList);
setUsers(userList);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
};
const openEdit = (tag: IdTag) => {
setEditing(tag);
setForm({
idTag: tag.idTag,
status: tag.status,
expiryDate: tag.expiryDate ? tag.expiryDate.slice(0, 10) : "",
parentIdTag: tag.parentIdTag ?? "",
userId: tag.userId ?? "",
balance: fenToYuan(tag.balance),
});
};
const handleSave = async () => {
setSaving(true);
try {
if (editing) {
await api.idTags.update(editing.idTag, {
status: form.status,
expiryDate: form.expiryDate || null,
parentIdTag: form.parentIdTag || null,
userId: form.userId || null,
balance: yuanToFen(form.balance),
});
} else {
await api.idTags.create({
idTag: form.idTag,
status: form.status,
expiryDate: form.expiryDate || undefined,
parentIdTag: form.parentIdTag || undefined,
userId: form.userId || undefined,
balance: yuanToFen(form.balance),
});
}
await load();
} finally {
setSaving(false);
}
};
const handleDelete = async (idTag: string) => {
setDeletingTag(idTag);
try {
await api.idTags.delete(idTag);
await load();
} finally {
setDeletingTag(null);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {tags.length} </p>
</div>
<Modal>
<Button variant="secondary" onPress={openCreate}>
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TagFormBody form={form} setForm={setForm} isEdit={false} users={users} />
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "创建"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="储值卡列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column className="text-end"></Table.Column>
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无储值卡"}
</div>
)}
>
{tags.map((tag) => {
const owner = users.find((u) => u.id === tag.userId);
return (
<Table.Row key={tag.idTag} id={tag.idTag}>
<Table.Cell className="font-mono font-medium">{tag.idTag}</Table.Cell>
<Table.Cell>
<Chip
color={statusColorMap[tag.status] ?? "warning"}
size="sm"
variant="soft"
>
{tag.status}
</Chip>
</Table.Cell>
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
<Table.Cell className="text-sm">
{owner ? (
<span title={owner.email}>
{owner.name ?? owner.username ?? owner.email}
</span>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
{tag.expiryDate ? (
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell className="font-mono">
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(tag.createdAt).toLocaleString("zh-CN")}
</Table.Cell>
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
<Modal>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => openEdit(tag)}
>
<Pencil className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TagFormBody
form={form}
setForm={setForm}
isEdit={true}
users={users}
/>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Delete button */}
<Button
isDisabled={deletingTag === tag.idTag}
isIconOnly
size="sm"
variant="danger-soft"
onPress={() => handleDelete(tag.idTag)}
>
{deletingTag === tag.idTag ? (
<Spinner size="sm" />
) : (
<TrashBin className="size-4" />
)}
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { ReactNode } from 'react'
import Sidebar from '@/components/sidebar'
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<div className="flex h-dvh bg-background">
<Sidebar />
{/* Main content */}
<div className="flex min-w-0 flex-1 flex-col">
<main className="flex-1 overflow-y-auto pt-14 lg:pt-0">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { Card } from "@heroui/react";
import { Thunderbolt, PlugConnection, CreditCard, ChartColumn } from "@gravity-ui/icons";
import { api } from "@/lib/api";
export const dynamic = "force-dynamic";
type CardColor = "accent" | "success" | "warning" | "default";
const colorStyles: Record<CardColor, { border: string; bg: string; icon: string }> = {
accent: { border: "border-accent", bg: "bg-accent/10", icon: "text-accent" },
success: { border: "border-success", bg: "bg-success/10", icon: "text-success" },
warning: { border: "border-warning", bg: "bg-warning/10", icon: "text-warning" },
default: { border: "border-border", bg: "bg-default", icon: "text-muted" },
};
function StatusDot({ color }: { color: "success" | "warning" | "muted" }) {
const cls =
color === "success" ? "bg-success" : color === "warning" ? "bg-warning" : "bg-muted/40";
return <span className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${cls}`} />;
}
function StatCard({
title,
value,
footer,
icon: Icon,
color = "default",
}: {
title: string;
value: string | number;
footer?: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
color?: CardColor;
}) {
const s = colorStyles[color];
return (
<Card className={`border-t-2 ${s.border}`}>
<Card.Content className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-muted">{title}</p>
{Icon && (
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
<Icon className={`size-4.5 ${s.icon}`} />
</div>
)}
</div>
<p className="text-3xl font-bold tabular-nums leading-none text-foreground">{value}</p>
{footer && (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
{footer}
</div>
)}
</Card.Content>
</Card>
);
}
export default async function DashboardPage() {
const stats = await api.stats.get().catch(() => null);
const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—";
const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0);
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
<StatCard
title="充电桩总数"
value={stats?.totalChargePoints ?? "—"}
icon={PlugConnection}
color="accent"
footer={
<>
<StatusDot color="success" />
<span className="font-medium text-success">
{stats?.onlineChargePoints ?? 0} 线
</span>
<span className="text-border">·</span>
<span>{offlineCount} 线</span>
</>
}
/>
<StatCard
title="在线充电桩"
value={stats?.onlineChargePoints ?? "—"}
icon={PlugConnection}
color="success"
footer={<span> 2 </span>}
/>
<StatCard
title="进行中充电"
value={stats?.activeTransactions ?? "—"}
icon={Thunderbolt}
color={stats?.activeTransactions ? "warning" : "default"}
footer={
<>
<StatusDot color={stats?.activeTransactions ? "success" : "muted"} />
<span className={stats?.activeTransactions ? "font-medium text-success" : ""}>
{stats?.activeTransactions ? "活跃中" : "当前空闲"}
</span>
</>
}
/>
<StatCard
title="储值卡总数"
value={stats?.totalIdTags ?? "—"}
icon={CreditCard}
color="default"
footer={<span></span>}
/>
<StatCard
title="今日充电量"
value={`${todayKwh} kWh`}
icon={ChartColumn}
color="accent"
footer={<span> 00:00 </span>}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,298 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import { TrashBin } from "@gravity-ui/icons";
import { api, type PaginatedTransactions } from "@/lib/api";
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);
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}h ${m}m`;
}
export default function TransactionsPage() {
const [data, setData] = useState<PaginatedTransactions | null>(null);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
const [loading, setLoading] = useState(true);
const [stoppingId, setStoppingId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const load = useCallback(async (p: number, s: typeof status) => {
setLoading(true);
try {
const res = await api.transactions.list({
page: p,
limit: LIMIT,
status: s === "all" ? undefined : s,
});
setData(res);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(page, status);
}, [page, status, load]);
const handleStatusChange = (s: typeof status) => {
setStatus(s);
setPage(1);
};
const handleStop = async (id: number) => {
setStoppingId(id);
try {
await api.transactions.stop(id);
await load(page, status);
} finally {
setStoppingId(null);
}
};
const handleDelete = async (id: number) => {
setDeletingId(id);
try {
await api.transactions.delete(id);
await load(page, status);
} finally {
setDeletingId(null);
}
};
const pages = data ? Array.from({ length: data.totalPages }, (_, i) => i + 1) : [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {data?.total ?? "—"} </p>
</div>
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
{(["all", "active", "completed"] as const).map((s) => (
<button
key={s}
onClick={() => handleStatusChange(s)}
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
status === s
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
>
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
</button>
))}
</div>
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="充电记录" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader>ID</Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column> (kWh)</Table.Column>
<Table.Column> ()</Table.Column>
<Table.Column></Table.Column>
<Table.Column>{""}</Table.Column>
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无记录"}
</div>
)}
>
{(data?.data ?? []).map((tx) => (
<Table.Row key={tx.id} id={tx.id}>
<Table.Cell className="font-mono text-sm">{tx.id}</Table.Cell>
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</Table.Cell>
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell>
{tx.stopTimestamp ? (
<Chip color="success" size="sm" variant="soft">
</Chip>
) : (
<Chip color="warning" size="sm" variant="soft">
</Chip>
)}
</Table.Cell>
<Table.Cell className="whitespace-nowrap text-sm">
{new Date(tx.startTimestamp).toLocaleString("zh-CN")}
</Table.Cell>
<Table.Cell className="text-sm">
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.energyWh != null ? (tx.energyWh / 1000).toFixed(3) : "—"}
</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.chargeAmount != null ? `¥${(tx.chargeAmount / 100).toFixed(2)}` : "—"}
</Table.Cell>
<Table.Cell>
{tx.stopReason ? (
<Chip color="default" size="sm" variant="soft">
{tx.stopReason}
</Chip>
) : tx.stopTimestamp ? (
<Chip color="default" size="sm" variant="soft">
Local
</Chip>
) : (
"—"
)}
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1">
{!tx.stopTimestamp && (
<Modal>
<Button size="sm" variant="danger-soft" isDisabled={stoppingId === tx.id}>
{stoppingId === tx.id ? <Spinner size="sm" /> : "中止"}
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
#{tx.id}
</span>
<span className="font-mono">{tx.idTag}</span>
线
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={stoppingId === tx.id}
onPress={() => handleStop(tx.id)}
>
{stoppingId === tx.id ? <Spinner size="sm" /> : "确认中止"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
<Modal>
<Button
isIconOnly
size="sm"
variant="tertiary"
isDisabled={deletingId === tx.id}
>
{deletingId === tx.id ? (
<Spinner size="sm" />
) : (
<TrashBin className="size-4" />
)}
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
#{tx.id}
</span>
<span className="font-mono">{tx.idTag}</span>
{!tx.stopTimestamp && "该记录仍进行中,删除同时将重置接口状态。"}
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deletingId === tx.id}
onPress={() => handleDelete(tx.id)}
>
{deletingId === tx.id ? <Spinner size="sm" /> : "确认删除"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
{data && data.totalPages > 1 && (
<Table.Footer>
<Pagination size="sm">
<Pagination.Summary>
{(page - 1) * LIMIT + 1}{Math.min(page * LIMIT, data.total)} {data.total}{" "}
</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
isDisabled={page === 1}
onPress={() => setPage((p) => Math.max(1, p - 1))}
>
<Pagination.PreviousIcon />
</Pagination.Previous>
</Pagination.Item>
{pages.map((p) => (
<Pagination.Item key={p}>
<Pagination.Link isActive={p === page} onPress={() => setPage(p)}>
{p}
</Pagination.Link>
</Pagination.Item>
))}
<Pagination.Item>
<Pagination.Next
isDisabled={page === data.totalPages}
onPress={() => setPage((p) => Math.min(data.totalPages, p + 1))}
>
<Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
</Table.Footer>
)}
</Table>
</div>
);
}

View File

@@ -0,0 +1,363 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Chip,
Input,
Label,
ListBox,
Modal,
Select,
Spinner,
Table,
TextField,
} from "@heroui/react";
import { Pencil } from "@gravity-ui/icons";
import { api, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
type CreateForm = {
name: string;
email: string;
username: string;
password: string;
role: string;
};
type EditForm = {
name: string;
username: string;
role: string;
};
const emptyCreate: CreateForm = { name: "", email: "", username: "", password: "", role: "user" };
const ROLE_OPTIONS = [
{ key: "user", label: "用户" },
{ key: "admin", label: "管理员" },
];
export default function UsersPage() {
const { data: session } = useSession();
const currentUserId = session?.user?.id;
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [createForm, setCreateForm] = useState<CreateForm>(emptyCreate);
const [editTarget, setEditTarget] = useState<UserRow | null>(null);
const [editForm, setEditForm] = useState<EditForm>({ name: "", username: "", role: "user" });
const [saving, setSaving] = useState(false);
const load = async () => {
setLoading(true);
try {
setUsers(await api.users.list());
} catch {
// possibly not admin — show empty
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const openEdit = (u: UserRow) => {
setEditTarget(u);
setEditForm({ name: u.name ?? "", username: u.username ?? "", role: u.role ?? "user" });
};
const handleCreate = async () => {
setSaving(true);
try {
await api.users.create({
name: createForm.name,
email: createForm.email,
password: createForm.password,
username: createForm.username || undefined,
role: createForm.role,
});
setCreateForm(emptyCreate);
await load();
} finally {
setSaving(false);
}
};
const handleEdit = async () => {
if (!editTarget) return;
setSaving(true);
try {
await api.users.update(editTarget.id, {
name: editForm.name || undefined,
username: editForm.username || null,
role: editForm.role,
});
await load();
} finally {
setSaving(false);
}
};
const toggleBan = async (u: UserRow) => {
setUpdating(u.id);
try {
await api.users.update(u.id, {
banned: !u.banned,
banReason: u.banned ? null : "管理员封禁",
});
await load();
} finally {
setUpdating(null);
}
};
const isEditingSelf = editTarget?.id === currentUserId;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {users.length} </p>
</div>
<Modal>
<Button variant="secondary" onPress={() => setCreateForm(emptyCreate)}>
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="用户姓名"
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="email"
placeholder="user@example.com"
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
className="font-mono"
placeholder="username"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="password"
placeholder="••••••••"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
/>
</TextField>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={createForm.role}
onSelectionChange={(key) =>
setCreateForm({ ...createForm, role: String(key) })
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{ROLE_OPTIONS.map((r) => (
<ListBox.Item key={r.key} id={r.key}>
{r.label}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
isDisabled={
saving || !createForm.name || !createForm.email || !createForm.password
}
slot="close"
onPress={handleCreate}
>
{saving ? <Spinner size="sm" /> : "创建"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="用户列表" className="min-w-187.5">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column className="text-end"></Table.Column>
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无用户或无权限"}
</div>
)}
>
{users.map((u) => (
<Table.Row key={u.id} id={u.id}>
<Table.Cell>
<div className="font-medium">{u.name ?? "—"}</div>
</Table.Cell>
<Table.Cell className="text-sm">{u.email}</Table.Cell>
<Table.Cell className="font-mono text-sm">{u.username ?? "—"}</Table.Cell>
<Table.Cell>
<Chip
color={u.role === "admin" ? "success" : "warning"}
size="sm"
variant="soft"
>
{u.role === "admin" ? "管理员" : "用户"}
</Chip>
</Table.Cell>
<Table.Cell>
{u.banned ? (
<Chip color="danger" size="sm" variant="soft">
</Chip>
) : (
<Chip color="success" size="sm" variant="soft">
</Chip>
)}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(u.createdAt).toLocaleString("zh-CN")}
</Table.Cell>
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
<Modal>
<Button isIconOnly size="sm" variant="tertiary" onPress={() => openEdit(u)}>
<Pencil className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="用户姓名"
value={editForm.name}
onChange={(e) =>
setEditForm({ ...editForm, name: e.target.value })
}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
className="font-mono"
placeholder="username"
value={editForm.username}
onChange={(e) =>
setEditForm({ ...editForm, username: e.target.value })
}
/>
</TextField>
{!isEditingSelf && (
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={editForm.role}
onSelectionChange={(key) =>
setEditForm({ ...editForm, role: String(key) })
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{ROLE_OPTIONS.map((r) => (
<ListBox.Item key={r.key} id={r.key}>
{r.label}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
)}
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleEdit}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Ban / Unban button — disabled for current account */}
<Button
isDisabled={u.id === currentUserId || updating === u.id}
size="sm"
variant={u.banned ? "tertiary" : "danger-soft"}
onPress={() => toggleBan(u)}
>
{updating === u.id ? <Spinner size="sm" /> : u.banned ? "解封" : "封禁"}
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
import { Thunderbolt } from "@gravity-ui/icons";
import { authClient } from "@/lib/auth-client";
export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await authClient.signIn.username({
username,
password,
fetchOptions: { credentials: "include" },
});
if (res.error) {
setError(res.error.message ?? "登录失败,请检查用户名和密码");
} else {
router.push("/dashboard");
router.refresh();
}
} catch {
setError("网络错误,请稍后重试");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-4">
{/* Brand */}
<div className="mb-8 flex flex-col items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent shadow-lg">
<Thunderbolt className="size-7 text-accent-foreground" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Helios EVCS</h1>
<p className="mt-1 text-sm text-muted"></p>
</div>
</div>
<Card className="w-full max-w-sm">
<Card.Content>
<form onSubmit={handleSubmit} className="space-y-4">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
required
autoComplete="username"
placeholder="admin"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
required
autoComplete="current-password"
placeholder="••••••••"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</TextField>
{error && (
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Title></Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setError("")} />
</Alert>
)}
<Button className="mt-2 w-full" isDisabled={loading} type="submit">
{loading ? "登录中…" : "登录"}
</Button>
</form>
</Card.Content>
</Card>
<p className="mt-6 text-xs text-muted/60">OCPP 1.6-J Protocol v0.1.0</p>
</div>
);
}

View File

@@ -1,67 +1,5 @@
import { Button } from "@heroui/react"; import { redirect } from 'next/navigation'
import Image from "next/image";
export default function Home() { export default function Home() {
return ( redirect('/dashboard')
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<Button>Hello</Button>
</div>
</main>
</div>
);
} }

View File

@@ -0,0 +1,48 @@
'use client'
import { useRouter } from 'next/navigation'
import { ArrowRightFromSquare, PersonFill } from '@gravity-ui/icons'
import { signOut, useSession } from '@/lib/auth-client'
export default function SidebarFooter() {
const router = useRouter()
const { data: session } = useSession()
const handleSignOut = async () => {
await signOut({ fetchOptions: { credentials: 'include' } })
router.push('/login')
router.refresh()
}
return (
<div className="border-t border-border p-3">
{/* User info */}
{session?.user && (
<div className="mb-2 flex items-center gap-2.5 rounded-lg px-2 py-1.5">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-accent-soft">
<PersonFill className="size-3.5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium leading-tight text-foreground">
{session.user.name || session.user.email}
</p>
<p className="truncate text-xs leading-tight text-muted capitalize">
{session.user.role ?? 'user'}
</p>
</div>
</div>
)}
<button
type="button"
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm text-muted transition-colors hover:bg-surface-tertiary hover:text-foreground"
onClick={handleSignOut}
>
<ArrowRightFromSquare className="size-4 shrink-0" />
<span>退</span>
</button>
<p className="mt-2 px-2 text-[11px] text-muted/60">OCPP 1.6-J v0.1.0</p>
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { CreditCard, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons'
import SidebarFooter from '@/components/sidebar-footer'
const navItems = [
{ href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true },
{ href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection },
{ href: '/dashboard/transactions', label: '充电记录', icon: ListCheck },
{ href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard },
{ href: '/dashboard/users', label: '用户管理', icon: Person },
]
function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) {
return (
<>
{/* Logo */}
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-5">
<div className="flex size-7 items-center justify-center rounded-lg bg-accent">
<Thunderbolt className="size-4 text-accent-foreground" />
</div>
<div>
<span className="text-sm font-semibold tracking-tight text-foreground">Helios EVCS</span>
</div>
</div>
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3">
<p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p>
{navItems.map((item) => {
const isActive = item.exact
? pathname === item.href
: pathname === item.href || pathname.startsWith(item.href + '/')
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={[
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent/10 text-accent'
: 'text-muted hover:bg-surface-tertiary hover:text-foreground',
].join(' ')}
>
<Icon className="size-4 shrink-0" />
<span>{item.label}</span>
{isActive && (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />
)}
</Link>
)
})}
</nav>
{/* Footer */}
<SidebarFooter />
</>
)
}
export default function Sidebar() {
const pathname = usePathname()
const [open, setOpen] = useState(false)
return (
<>
{/* Mobile top bar */}
<div className="fixed inset-x-0 top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-surface-secondary px-4 lg:hidden">
<button
type="button"
className="flex size-8 items-center justify-center rounded-lg text-muted transition-colors hover:bg-surface-tertiary hover:text-foreground"
onClick={() => setOpen(true)}
aria-label="打开菜单"
>
<Bars className="size-5" />
</button>
<div className="flex items-center gap-2">
<div className="flex size-6 items-center justify-center rounded-md bg-accent">
<Thunderbolt className="size-3.5 text-accent-foreground" />
</div>
<span className="text-sm font-semibold">Helios EVCS</span>
</div>
</div>
{/* Mobile drawer overlay */}
{open && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
{/* Mobile drawer */}
<aside
className={[
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-border bg-surface-secondary transition-transform duration-300 lg:hidden',
open ? 'translate-x-0' : '-translate-x-full',
].join(' ')}
>
<button
type="button"
className="absolute right-3 top-3 flex size-8 items-center justify-center rounded-lg text-muted transition-colors hover:bg-surface-tertiary hover:text-foreground"
onClick={() => setOpen(false)}
aria-label="关闭菜单"
>
<Xmark className="size-4" />
</button>
<NavContent pathname={pathname} onNavigate={() => setOpen(false)} />
</aside>
{/* Desktop sidebar */}
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface-secondary lg:flex">
<NavContent pathname={pathname} />
</aside>
</>
)
}

175
apps/web/lib/api.ts Normal file
View File

@@ -0,0 +1,175 @@
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${CSMS_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
credentials: "include",
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`API ${path} failed (${res.status}): ${text}`);
}
return res.json() as Promise<T>;
}
// ── Types ──────────────────────────────────────────────────────────────────
export type Stats = {
totalChargePoints: number;
onlineChargePoints: number;
activeTransactions: number;
totalIdTags: number;
todayEnergyWh: number;
};
export type ConnectorSummary = {
id: number;
connectorId: number;
status: string;
lastStatusAt: string | null;
};
export type ChargePoint = {
id: number;
chargePointIdentifier: string;
chargePointVendor: string | null;
chargePointModel: string | null;
registrationStatus: string;
lastHeartbeatAt: string | null;
lastBootNotificationAt: string | null;
feePerKwh: number;
connectors: ConnectorSummary[];
};
export type Transaction = {
id: number;
chargePointIdentifier: string | null;
connectorNumber: number | null;
idTag: string;
idTagStatus: string | null;
startTimestamp: string;
stopTimestamp: string | null;
startMeterValue: number | null;
stopMeterValue: number | null;
energyWh: number | null;
stopIdTag: string | null;
stopReason: string | null;
chargeAmount: number | null;
};
export type IdTag = {
idTag: string;
status: string;
expiryDate: string | null;
parentIdTag: string | null;
userId: string | null;
balance: number;
createdAt: string;
};
export type UserRow = {
id: string;
name: string | null;
email: string;
emailVerified: boolean;
username: string | null;
role: string | null;
banned: boolean | null;
banReason: string | null;
createdAt: string;
};
export type PaginatedTransactions = {
data: Transaction[];
total: number;
page: number;
totalPages: number;
};
// ── API functions ──────────────────────────────────────────────────────────
export const api = {
stats: {
get: () => apiFetch<Stats>("/api/stats"),
},
chargePoints: {
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
get: (id: number) => apiFetch<ChargePoint>(`/api/charge-points/${id}`),
update: (id: string, data: { feePerKwh: number }) =>
apiFetch<{ feePerKwh: number }>(`/api/charge-points/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
delete: (id: string) =>
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
},
transactions: {
list: (params?: { page?: number; limit?: number; status?: "active" | "completed" }) => {
const q = new URLSearchParams();
if (params?.page) q.set("page", String(params.page));
if (params?.limit) q.set("limit", String(params.limit));
if (params?.status) q.set("status", params.status);
const qs = q.toString();
return apiFetch<PaginatedTransactions>(`/api/transactions${qs ? "?" + qs : ""}`);
},
get: (id: number) => apiFetch<Transaction>(`/api/transactions/${id}`),
stop: (id: number) =>
apiFetch<Transaction & { online: boolean }>(`/api/transactions/${id}/stop`, {
method: "POST",
}),
delete: (id: number) =>
apiFetch<{ success: true }>(`/api/transactions/${id}`, { method: "DELETE" }),
},
idTags: {
list: () => apiFetch<IdTag[]>("/api/id-tags"),
get: (idTag: string) => apiFetch<IdTag>(`/api/id-tags/${idTag}`),
create: (data: {
idTag: string;
status?: string;
expiryDate?: string;
parentIdTag?: string;
userId?: string | null;
balance?: number;
}) => apiFetch<IdTag>("/api/id-tags", { method: "POST", body: JSON.stringify(data) }),
update: (
idTag: string,
data: {
status?: string;
expiryDate?: string | null;
parentIdTag?: string | null;
userId?: string | null;
balance?: number;
},
) => apiFetch<IdTag>(`/api/id-tags/${idTag}`, { method: "PATCH", body: JSON.stringify(data) }),
delete: (idTag: string) =>
apiFetch<{ success: true }>(`/api/id-tags/${idTag}`, { method: "DELETE" }),
},
users: {
list: () => apiFetch<UserRow[]>("/api/users"),
create: (data: {
name: string;
email: string;
password: string;
username?: string;
role?: string;
}) =>
apiFetch<{ user: UserRow }>("/api/auth/admin/create-user", {
method: "POST",
body: JSON.stringify(data),
}),
update: (
id: string,
data: {
name?: string;
username?: string | null;
role?: string;
banned?: boolean;
banReason?: string | null;
},
) => apiFetch<UserRow>(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
},
};

View File

@@ -0,0 +1,9 @@
import { createAuthClient } from "better-auth/react";
import { adminClient, usernameClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001",
plugins: [usernameClient(), adminClient()],
});
export const { signIn, signOut, signUp, useSession } = authClient;

27
apps/web/middleware.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 只保护 /dashboard 路由
if (!pathname.startsWith("/dashboard")) {
return NextResponse.next();
}
// 检查 better-auth session cookiecookie 前缀是 helios_auth
const sessionCookie =
request.cookies.get("helios_auth.session_token") ??
request.cookies.get("__Secure-helios_auth.session_token");
if (!sessionCookie) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};

View File

@@ -10,11 +10,14 @@
"dependencies": { "dependencies": {
"@heroui/react": "3.0.0-beta.8", "@heroui/react": "3.0.0-beta.8",
"@heroui/styles": "3.0.0-beta.8", "@heroui/styles": "3.0.0-beta.8",
"@internationalized/date": "^3.12.0",
"better-auth": "^1.3.34",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@gravity-ui/icons": "^2.18.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",