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:
1667
apps/csms/drizzle/meta/0003_snapshot.json
Normal file
1667
apps/csms/drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,13 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/node-ws": "^1.2.0",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"better-auth": "^1.3.34",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"hono": "^4.10.6",
|
||||
"pg": "^8.16.3"
|
||||
"pg": "^8.16.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.3.34",
|
||||
|
||||
@@ -66,6 +66,12 @@ export const chargePoint = pgTable('charge_point', {
|
||||
lastBootNotificationAt: timestamp('last_boot_notification_at', {
|
||||
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 })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
@@ -271,6 +277,11 @@ export const idTag = pgTable('id_tag', {
|
||||
* 允许将 RFID 卡与注册用户绑定,支持 Web/App 远程查询充电记录
|
||||
*/
|
||||
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 })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
@@ -361,6 +372,12 @@ export const transaction = pgTable(
|
||||
'DeAuthorized',
|
||||
],
|
||||
}),
|
||||
/**
|
||||
* 本次充电扣费金额(单位:分)
|
||||
* 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入
|
||||
* null 表示未计费(如免费充电桩或交易异常终止)
|
||||
*/
|
||||
chargeAmount: integer('charge_amount'),
|
||||
/**
|
||||
* 关联的预约 ID(若本次充电由预约触发)
|
||||
* StartTransaction.req.reservationId(optional)
|
||||
|
||||
@@ -8,13 +8,15 @@ import { logger } from 'hono/logger'
|
||||
import { showRoutes } from 'hono/dev'
|
||||
import { auth } from './lib/auth.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<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null
|
||||
session: typeof auth.$Infer.Session.session | null
|
||||
}
|
||||
}>()
|
||||
import type { HonoEnv } from './types/hono.ts'
|
||||
|
||||
const app = new Hono<HonoEnv>()
|
||||
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
||||
|
||||
@@ -34,10 +36,10 @@ app.use('*', async (c, next) => {
|
||||
})
|
||||
|
||||
app.use(
|
||||
'/api/auth/*',
|
||||
'/api/*',
|
||||
cors({
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'OPTIONS'],
|
||||
origin: process.env.WEB_ORIGIN ?? '*',
|
||||
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
exposeHeaders: ['Content-Length'],
|
||||
credentials: true,
|
||||
@@ -46,6 +48,13 @@ app.use(
|
||||
|
||||
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) => {
|
||||
const user = c.get('user')
|
||||
const session = c.get('session')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { useDrizzle } from './db.js'
|
||||
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({
|
||||
database: drizzleAdapter(useDrizzle(), {
|
||||
@@ -11,13 +11,16 @@ export const auth = betterAuth({
|
||||
...schema,
|
||||
},
|
||||
}),
|
||||
trustedOrigins: [
|
||||
process.env.WEB_ORIGIN ?? 'http://localhost:3000',
|
||||
],
|
||||
user: {
|
||||
additionalFields: {},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [admin(), username(), bearer(), jwt()],
|
||||
plugins: [admin(), username(), bearer()],
|
||||
advanced: {
|
||||
cookiePrefix: 'helios_auth',
|
||||
},
|
||||
|
||||
51
apps/csms/src/ocpp/actions/authorize.ts
Normal file
51
apps/csms/src/ocpp/actions/authorize.ts
Normal 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 };
|
||||
}
|
||||
51
apps/csms/src/ocpp/actions/meter-values.ts
Normal file
51
apps/csms/src/ocpp/actions/meter-values.ts
Normal 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 {};
|
||||
}
|
||||
85
apps/csms/src/ocpp/actions/start-transaction.ts
Normal file
85
apps/csms/src/ocpp/actions/start-transaction.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { useDrizzle } from '@/lib/db.js'
|
||||
import { chargePoint, connector, connectorStatusHistory } from '@/db/schema.js'
|
||||
import type {
|
||||
|
||||
101
apps/csms/src/ocpp/actions/stop-transaction.ts
Normal file
101
apps/csms/src/ocpp/actions/stop-transaction.ts
Normal 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 };
|
||||
}
|
||||
@@ -6,19 +6,41 @@ import {
|
||||
type OcppErrorCode,
|
||||
type OcppMessage,
|
||||
type OcppConnectionContext,
|
||||
type AuthorizeRequest,
|
||||
type AuthorizeResponse,
|
||||
type BootNotificationRequest,
|
||||
type BootNotificationResponse,
|
||||
type HeartbeatRequest,
|
||||
type HeartbeatResponse,
|
||||
type MeterValuesRequest,
|
||||
type MeterValuesResponse,
|
||||
type StartTransactionRequest,
|
||||
type StartTransactionResponse,
|
||||
type StatusNotificationRequest,
|
||||
type StatusNotificationResponse,
|
||||
type StopTransactionRequest,
|
||||
type StopTransactionResponse,
|
||||
} 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 { 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 { handleStopTransaction } from './actions/stop-transaction.ts'
|
||||
|
||||
// Typed dispatch map — only registered actions are accepted
|
||||
type ActionHandlerMap = {
|
||||
Authorize: (
|
||||
payload: AuthorizeRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
) => Promise<AuthorizeResponse>
|
||||
BootNotification: (
|
||||
payload: BootNotificationRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
@@ -27,16 +49,32 @@ type ActionHandlerMap = {
|
||||
payload: HeartbeatRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
) => Promise<HeartbeatResponse>
|
||||
MeterValues: (
|
||||
payload: MeterValuesRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
) => Promise<MeterValuesResponse>
|
||||
StartTransaction: (
|
||||
payload: StartTransactionRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
) => Promise<StartTransactionResponse>
|
||||
StatusNotification: (
|
||||
payload: StatusNotificationRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
) => Promise<StatusNotificationResponse>
|
||||
StopTransaction: (
|
||||
payload: StopTransactionRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
) => Promise<StopTransactionResponse>
|
||||
}
|
||||
|
||||
const actionHandlers: ActionHandlerMap = {
|
||||
Authorize: handleAuthorize,
|
||||
BootNotification: handleBootNotification,
|
||||
Heartbeat: handleHeartbeat,
|
||||
MeterValues: handleMeterValues,
|
||||
StartTransaction: handleStartTransaction,
|
||||
StatusNotification: handleStatusNotification,
|
||||
StopTransaction: handleStopTransaction,
|
||||
}
|
||||
|
||||
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')
|
||||
return
|
||||
}
|
||||
ocppConnections.set(chargePointIdentifier, ws)
|
||||
console.log(
|
||||
`[OCPP] ${chargePointIdentifier} connected` +
|
||||
(remoteAddr ? ` from ${remoteAddr}` : ''),
|
||||
@@ -136,6 +175,7 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
|
||||
},
|
||||
|
||||
onClose(evt: CloseEvent, _ws: WSContext) {
|
||||
ocppConnections.delete(chargePointIdentifier)
|
||||
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -82,10 +82,64 @@ export type AuthorizeRequest = {
|
||||
idTag: string // CiString20Type
|
||||
}
|
||||
|
||||
export type AuthorizeResponse = {
|
||||
idTagInfo: {
|
||||
export type IdTagInfo = {
|
||||
status: 'Accepted' | 'Blocked' | 'Expired' | 'Invalid' | 'ConcurrentTx'
|
||||
expiryDate?: 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>
|
||||
|
||||
92
apps/csms/src/routes/charge-points.ts
Normal file
92
apps/csms/src/routes/charge-points.ts
Normal 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;
|
||||
85
apps/csms/src/routes/id-tags.ts
Normal file
85
apps/csms/src/routes/id-tags.ts
Normal 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;
|
||||
45
apps/csms/src/routes/stats.ts
Normal file
45
apps/csms/src/routes/stats.ts
Normal 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;
|
||||
194
apps/csms/src/routes/transactions.ts
Normal file
194
apps/csms/src/routes/transactions.ts
Normal 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;
|
||||
76
apps/csms/src/routes/users.ts
Normal file
76
apps/csms/src/routes/users.ts
Normal 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;
|
||||
8
apps/csms/src/types/hono.ts
Normal file
8
apps/csms/src/types/hono.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
275
apps/web/app/dashboard/charge-points/page.tsx
Normal file
275
apps/web/app/dashboard/charge-points/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
484
apps/web/app/dashboard/id-tags/page.tsx
Normal file
484
apps/web/app/dashboard/id-tags/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/web/app/dashboard/layout.tsx
Normal file
19
apps/web/app/dashboard/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
apps/web/app/dashboard/page.tsx
Normal file
126
apps/web/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
apps/web/app/dashboard/transactions/page.tsx
Normal file
298
apps/web/app/dashboard/transactions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
apps/web/app/dashboard/users/page.tsx
Normal file
363
apps/web/app/dashboard/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
apps/web/app/login/page.tsx
Normal file
96
apps/web/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,5 @@
|
||||
import { Button } from "@heroui/react";
|
||||
import Image from "next/image";
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
48
apps/web/components/sidebar-footer.tsx
Normal file
48
apps/web/components/sidebar-footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
apps/web/components/sidebar.tsx
Normal file
124
apps/web/components/sidebar.tsx
Normal 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
175
apps/web/lib/api.ts
Normal 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) }),
|
||||
},
|
||||
};
|
||||
9
apps/web/lib/auth-client.ts
Normal file
9
apps/web/lib/auth-client.ts
Normal 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
27
apps/web/middleware.ts
Normal 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 cookie(cookie 前缀是 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*"],
|
||||
};
|
||||
@@ -10,11 +10,14 @@
|
||||
"dependencies": {
|
||||
"@heroui/react": "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",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gravity-ui/icons": "^2.18.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
|
||||
Reference in New Issue
Block a user