feat: RBAC controlling

This commit is contained in:
2026-03-10 17:59:44 +08:00
parent f803a447b5
commit b9c0f3025c
11 changed files with 716 additions and 380 deletions

View File

@@ -2,14 +2,20 @@ import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm"; import { desc, eq, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js"; import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js"; import { chargePoint, connector } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono(); const app = new Hono<HonoEnv>();
/** GET /api/charge-points — list all charge points with connectors */ /** GET /api/charge-points — list all charge points with connectors */
app.get("/", async (c) => { app.get("/", async (c) => {
const db = useDrizzle(); const db = useDrizzle();
const isAdmin = c.get("user")?.role === "admin";
const cps = await db.select().from(chargePoint).orderBy(desc(chargePoint.createdAt)); const cps = await db
.select()
.from(chargePoint)
.where(isAdmin ? undefined : eq(chargePoint.registrationStatus, "Accepted"))
.orderBy(desc(chargePoint.createdAt));
// Attach connectors (connectorId > 0 only, excludes the main-controller row) // Attach connectors (connectorId > 0 only, excludes the main-controller row)
const connectors = cps.length const connectors = cps.length
@@ -37,6 +43,7 @@ app.get("/", async (c) => {
/** POST /api/charge-points — manually pre-register a charge point */ /** POST /api/charge-points — manually pre-register a charge point */
app.post("/", async (c) => { app.post("/", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle(); const db = useDrizzle();
const body = await c.req.json<{ const body = await c.req.json<{
chargePointIdentifier: string; chargePointIdentifier: string;
@@ -88,6 +95,7 @@ app.get("/:id", async (c) => {
/** PATCH /api/charge-points/:id — update charge point fields */ /** PATCH /api/charge-points/:id — update charge point fields */
app.patch("/:id", async (c) => { app.patch("/:id", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle(); const db = useDrizzle();
const id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json<{ const body = await c.req.json<{
@@ -134,6 +142,7 @@ app.patch("/:id", async (c) => {
/** DELETE /api/charge-points/:id — delete a charge point (cascades to connectors, transactions, meter values) */ /** DELETE /api/charge-points/:id — delete a charge point (cascades to connectors, transactions, meter values) */
app.delete("/:id", async (c) => { app.delete("/:id", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle(); const db = useDrizzle();
const id = c.req.param("id"); const id = c.req.param("id");

View File

@@ -4,8 +4,9 @@ import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js"; import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod"; import { z } from "zod";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono(); const app = new Hono<HonoEnv>();
const idTagSchema = z.object({ const idTagSchema = z.object({
idTag: z.string().min(1).max(20), idTag: z.string().min(1).max(20),
@@ -18,9 +19,32 @@ const idTagSchema = z.object({
const idTagUpdateSchema = idTagSchema.partial().omit({ idTag: true }); const idTagUpdateSchema = idTagSchema.partial().omit({ idTag: true });
/** Generate an 8-character OCPP-compatible idTag (0-9 + A-F uppercase) */
function generateIdTag(): string {
const chars = "0123456789ABCDEF";
let result = "";
for (let i = 0; i < 8; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
/** GET /api/id-tags */ /** GET /api/id-tags */
app.get("/", async (c) => { app.get("/", async (c) => {
const db = useDrizzle(); const db = useDrizzle();
const currentUser = c.get("user");
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
// Non-admin users only see their own cards
if (currentUser.role !== "admin") {
const tags = await db
.select()
.from(idTag)
.where(eq(idTag.userId, currentUser.id))
.orderBy(desc(idTag.createdAt));
return c.json(tags);
}
const tags = await db.select().from(idTag).orderBy(desc(idTag.createdAt)); const tags = await db.select().from(idTag).orderBy(desc(idTag.createdAt));
return c.json(tags); return c.json(tags);
}); });
@@ -34,31 +58,69 @@ app.get("/:id", async (c) => {
return c.json(tag); return c.json(tag);
}); });
/** POST /api/id-tags */ /** POST /api/id-tags — admin only, create arbitrary card */
app.post("/", zValidator("json", idTagSchema), async (c) => { app.post("/", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle(); const db = useDrizzle();
const body = c.req.valid("json"); const body = await c.req.json().catch(() => {});
const parsed = idTagSchema.safeParse(body);
if (!parsed.success) return c.json({ error: parsed.error.issues }, 400);
const [created] = await db const [created] = await db
.insert(idTag) .insert(idTag)
.values({ .values({
...body, ...parsed.data,
expiryDate: body.expiryDate ? new Date(body.expiryDate) : null, expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : null,
}) })
.returning(); .returning();
return c.json(created, 201); return c.json(created, 201);
}); });
/** PATCH /api/id-tags/:id */ /** POST /api/id-tags/claim — user claims a new card assigned to themselves */
app.patch("/:id", zValidator("json", idTagUpdateSchema), async (c) => { app.post("/claim", async (c) => {
const currentUser = c.get("user");
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
const db = useDrizzle();
// Retry up to 5 times in case of collision
for (let attempt = 0; attempt < 5; attempt++) {
const newTag = generateIdTag();
const existing = await db
.select({ idTag: idTag.idTag })
.from(idTag)
.where(eq(idTag.idTag, newTag))
.limit(1);
if (existing.length > 0) continue;
const [created] = await db
.insert(idTag)
.values({
idTag: newTag,
status: "Accepted",
userId: currentUser.id,
balance: 0,
})
.returning();
return c.json(created, 201);
}
return c.json({ error: "Failed to generate unique idTag, please retry" }, 500);
});
/** PATCH /api/id-tags/:id — admin only */
app.patch("/:id", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle(); const db = useDrizzle();
const tagId = c.req.param("id"); const tagId = c.req.param("id");
const body = c.req.valid("json"); const body = await c.req.json().catch(() => {});
const parsed = idTagUpdateSchema.safeParse(body);
if (!parsed.success) return c.json({ error: parsed.error.issues }, 400);
const [updated] = await db const [updated] = await db
.update(idTag) .update(idTag)
.set({ .set({
...body, ...parsed.data,
expiryDate: body.expiryDate ? new Date(body.expiryDate) : undefined, expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : undefined,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(idTag.idTag, tagId)) .where(eq(idTag.idTag, tagId))
@@ -68,8 +130,9 @@ app.patch("/:id", zValidator("json", idTagUpdateSchema), async (c) => {
return c.json(updated); return c.json(updated);
}); });
/** DELETE /api/id-tags/:id */ /** DELETE /api/id-tags/:id — admin only */
app.delete("/:id", async (c) => { app.delete("/:id", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle(); const db = useDrizzle();
const tagId = c.req.param("id"); const tagId = c.req.param("id");

View File

@@ -1,30 +1,29 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { isNull, sql } from "drizzle-orm"; import { eq, isNull, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js"; import { useDrizzle } from "@/lib/db.js";
import { chargePoint, transaction, idTag } from "@/db/schema.js"; import { chargePoint, transaction, idTag } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono(); const app = new Hono<HonoEnv>();
app.get("/", async (c) => { app.get("/", async (c) => {
const db = useDrizzle(); const db = useDrizzle();
const currentUser = c.get("user");
const isAdmin = currentUser?.role === "admin";
if (isAdmin) {
const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] = const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] =
await Promise.all([ await Promise.all([
// Total charge points
db.select({ count: sql<number>`count(*)::int` }).from(chargePoint), db.select({ count: sql<number>`count(*)::int` }).from(chargePoint),
// Online charge points (received heartbeat in last 2×heartbeat interval, default 120s)
db db
.select({ count: sql<number>`count(*)::int` }) .select({ count: sql<number>`count(*)::int` })
.from(chargePoint) .from(chargePoint)
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`), .where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
// Active (in-progress) transactions
db db
.select({ count: sql<number>`count(*)::int` }) .select({ count: sql<number>`count(*)::int` })
.from(transaction) .from(transaction)
.where(isNull(transaction.stopTimestamp)), .where(isNull(transaction.stopTimestamp)),
// Total id tags
db.select({ count: sql<number>`count(*)::int` }).from(idTag), db.select({ count: sql<number>`count(*)::int` }).from(idTag),
// Energy dispensed today (sum of stopMeterValue - startMeterValue for transactions ending today)
db db
.select({ .select({
total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`, total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
@@ -40,6 +39,44 @@ app.get("/", async (c) => {
totalIdTags: totalIdTags[0].count, totalIdTags: totalIdTags[0].count,
todayEnergyWh: todayEnergy[0].total, todayEnergyWh: todayEnergy[0].total,
}); });
}
// User-scoped stats
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
const userId = currentUser.id;
const [userIdTags, totalBalance, activeCount, totalTxCount] = await Promise.all([
// Cards belonging to this user
db
.select({ count: sql<number>`count(*)::int` })
.from(idTag)
.where(eq(idTag.userId, userId)),
// Sum of balances
db
.select({ total: sql<number>`coalesce(sum(${idTag.balance}), 0)::int` })
.from(idTag)
.where(eq(idTag.userId, userId)),
// Active transactions for user's cards
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.innerJoin(idTag, eq(transaction.idTag, idTag.idTag))
.where(sql`${isNull(transaction.stopTimestamp)} and ${idTag.userId} = ${userId}`),
// Total transactions for user's cards
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.innerJoin(idTag, eq(transaction.idTag, idTag.idTag))
.where(eq(idTag.userId, userId)),
]);
return c.json({
totalIdTags: userIdTags[0].count,
totalBalance: totalBalance[0].total,
activeTransactions: activeCount[0].count,
totalTransactions: totalTxCount[0].count,
});
}); });
export default app; export default app;

View File

@@ -4,10 +4,11 @@ import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js"; import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import { ocppConnections } from "@/ocpp/handler.js"; import { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js"; import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono(); const app = new Hono<HonoEnv>();
/** GET /api/transactions?page=1&limit=20&status=active|completed */ /** GET /api/transactions?page=1&limit=20&status=active|completed&chargePointId=... */
app.get("/", async (c) => { app.get("/", async (c) => {
const page = Math.max(1, Number(c.req.query("page") ?? 1)); 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 limit = Math.min(100, Math.max(1, Number(c.req.query("limit") ?? 20)));
@@ -16,6 +17,8 @@ app.get("/", async (c) => {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const db = useDrizzle(); const db = useDrizzle();
const currentUser = c.get("user");
const isAdmin = currentUser?.role === "admin";
const statusCondition = const statusCondition =
status === "active" status === "active"
@@ -24,11 +27,15 @@ app.get("/", async (c) => {
? isNotNull(transaction.stopTimestamp) ? isNotNull(transaction.stopTimestamp)
: undefined; : undefined;
const whereClause = chargePointId // For non-admin users, restrict to transactions matching their id-tags
? statusCondition const userCondition =
? and(statusCondition, eq(transaction.chargePointId, chargePointId)) !isAdmin && currentUser
: eq(transaction.chargePointId, chargePointId) ? sql`${transaction.idTag} in (select id_tag from id_tag where user_id = ${currentUser.id})`
: statusCondition; : undefined;
const cpCondition = chargePointId ? eq(transaction.chargePointId, chargePointId) : undefined;
const whereClause = and(statusCondition, userCondition, cpCondition);
const [{ total }] = await db const [{ total }] = await db
.select({ total: sql<number>`count(*)::int` }) .select({ total: sql<number>`count(*)::int` })
@@ -174,6 +181,7 @@ app.post("/:id/stop", async (c) => {
/** DELETE /api/transactions/:id — delete a transaction record */ /** DELETE /api/transactions/:id — delete a transaction record */
app.delete("/:id", async (c) => { app.delete("/:id", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle(); const db = useDrizzle();
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));

View File

@@ -17,6 +17,7 @@ import {
} from "@heroui/react"; } from "@heroui/react";
import { ArrowLeft, Pencil, PlugConnection } from "@gravity-ui/icons"; import { ArrowLeft, Pencil, PlugConnection } from "@gravity-ui/icons";
import { api, type ChargePointDetail, type PaginatedTransactions } from "@/lib/api"; import { api, type ChargePointDetail, type PaginatedTransactions } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
// ── Status maps ──────────────────────────────────────────────────────────── // ── Status maps ────────────────────────────────────────────────────────────
@@ -182,6 +183,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
cp?.lastHeartbeatAt != null && cp?.lastHeartbeatAt != null &&
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < (cp.heartbeatInterval ?? 60) * 3 * 1000; Date.now() - new Date(cp.lastHeartbeatAt).getTime() < (cp.heartbeatInterval ?? 60) * 3 * 1000;
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
// ── Render: loading / not found ────────────────────────────────────────── // ── Render: loading / not found ──────────────────────────────────────────
if (loading) { if (loading) {
@@ -249,15 +253,18 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</p> </p>
)} )}
</div> </div>
{isAdmin && (
<Button size="sm" variant="secondary" onPress={openEdit}> <Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
)}
</div> </div>
{/* Info grid */} {/* Info grid */}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{/* Device info */} {/* Device info — admin only */}
{isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4"> <div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2> <h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border"> <dl className="divide-y divide-border">
@@ -280,8 +287,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
))} ))}
</dl> </dl>
</div> </div>
)}
{/* Operation info */} {/* Operation info — admin only */}
{isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4"> <div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2> <h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border"> <dl className="divide-y divide-border">
@@ -354,6 +363,38 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</div> </div>
</dl> </dl>
</div> </div>
)}
{/* Fee info — user only */}
{!isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border">
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{cp.feePerKwh > 0 ? (
<span>
<span className="font-semibold">¥{(cp.feePerKwh / 100).toFixed(2)}</span>
<span className="ml-1 text-xs text-muted">/kWh</span>
</span>
) : (
<span className="text-success font-medium"></span>
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd>
<div className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`} />
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
</div>
</dd>
</div>
</dl>
</div>
)}
</div> </div>
{/* Connectors */} {/* Connectors */}
@@ -506,6 +547,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</div> </div>
{/* Edit modal */} {/* Edit modal */}
{isAdmin && (
<Modal <Modal
isOpen={editOpen} isOpen={editOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -591,6 +633,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
)}
</div> </div>
); );
} }

View File

@@ -1,11 +1,22 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Chip, Input, Label, ListBox, Modal, Select, Spinner, Table, TextField } from "@heroui/react"; import {
Button,
Chip,
Input,
Label,
ListBox,
Modal,
Select,
Spinner,
Table,
TextField,
} from "@heroui/react";
import { Plus, Pencil, PlugConnection, TrashBin } from "@gravity-ui/icons"; import { Plus, Pencil, PlugConnection, TrashBin } from "@gravity-ui/icons";
import Link from "next/link"; import Link from "next/link";
import { api, type ChargePoint } from "@/lib/api"; import { api, type ChargePoint } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
const statusLabelMap: Record<string, string> = { const statusLabelMap: Record<string, string> = {
Available: "空闲中", Available: "空闲中",
@@ -109,7 +120,9 @@ export default function ChargePointsPage() {
feePerKwh: fee, feePerKwh: fee,
}); });
setChargePoints((prev) => setChargePoints((prev) =>
prev.map((cp) => (cp.id === formTarget.id ? { ...updated, connectors: cp.connectors } : cp)), prev.map((cp) =>
cp.id === formTarget.id ? { ...updated, connectors: cp.connectors } : cp,
),
); );
} else { } else {
// Create // Create
@@ -142,6 +155,9 @@ export default function ChargePointsPage() {
const isEdit = formTarget !== null; const isEdit = formTarget !== null;
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
@@ -149,14 +165,23 @@ export default function ChargePointsPage() {
<h1 className="text-xl font-semibold text-foreground"></h1> <h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p> <p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p>
</div> </div>
{isAdmin && (
<Button size="sm" variant="secondary" onPress={openCreate}> <Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" /> <Plus className="size-4" />
</Button> </Button>
)}
</div> </div>
{/* Create / Edit modal */} {/* Create / Edit modal — admin only */}
<Modal isOpen={formOpen} onOpenChange={(open) => { if (!formBusy) setFormOpen(open); }}> {isAdmin && (
<>
<Modal
isOpen={formOpen}
onOpenChange={(open) => {
if (!formBusy) setFormOpen(open);
}}
>
<Modal.Backdrop> <Modal.Backdrop>
<Modal.Container scroll="outside"> <Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md"> <Modal.Dialog className="sm:max-w-md">
@@ -170,7 +195,9 @@ export default function ChargePointsPage() {
<Input <Input
placeholder="CP001" placeholder="CP001"
value={formData.chargePointIdentifier} value={formData.chargePointIdentifier}
onChange={(e) => setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))} onChange={(e) =>
setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))
}
/> />
</TextField> </TextField>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -179,7 +206,9 @@ export default function ChargePointsPage() {
<Input <Input
placeholder="ABB" placeholder="ABB"
value={formData.chargePointVendor} value={formData.chargePointVendor}
onChange={(e) => setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))} onChange={(e) =>
setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))
}
/> />
</TextField> </TextField>
<TextField fullWidth> <TextField fullWidth>
@@ -187,7 +216,9 @@ export default function ChargePointsPage() {
<Input <Input
placeholder="Terra AC" placeholder="Terra AC"
value={formData.chargePointModel} value={formData.chargePointModel}
onChange={(e) => setFormData((f) => ({ ...f, chargePointModel: e.target.value }))} onChange={(e) =>
setFormData((f) => ({ ...f, chargePointModel: e.target.value }))
}
/> />
</TextField> </TextField>
</div> </div>
@@ -197,7 +228,10 @@ export default function ChargePointsPage() {
fullWidth fullWidth
selectedKey={formData.registrationStatus} selectedKey={formData.registrationStatus}
onSelectionChange={(key) => onSelectionChange={(key) =>
setFormData((f) => ({ ...f, registrationStatus: String(key) as FormData["registrationStatus"] })) setFormData((f) => ({
...f,
registrationStatus: String(key) as FormData["registrationStatus"],
}))
} }
> >
<Select.Trigger> <Select.Trigger>
@@ -245,18 +279,20 @@ export default function ChargePointsPage() {
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
</>
)}
<Table> <Table>
<Table.ScrollContainer> <Table.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200"> <Table.Content aria-label="充电桩列表" className="min-w-200">
<Table.Header> <Table.Header>
<Table.Column isRowHeader></Table.Column> <Table.Column isRowHeader></Table.Column>
<Table.Column> / </Table.Column> {isAdmin && <Table.Column> / </Table.Column>}
<Table.Column></Table.Column> {isAdmin && <Table.Column></Table.Column>}
<Table.Column>/kWh</Table.Column> <Table.Column>/kWh</Table.Column>
<Table.Column></Table.Column> <Table.Column></Table.Column>
<Table.Column></Table.Column> <Table.Column></Table.Column>
<Table.Column>{""}</Table.Column> {isAdmin && <Table.Column>{""}</Table.Column>}
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{chargePoints.length === 0 && ( {chargePoints.length === 0 && (
@@ -264,12 +300,12 @@ export default function ChargePointsPage() {
<Table.Cell> <Table.Cell>
<span className="text-muted text-sm"></span> <span className="text-muted text-sm"></span>
</Table.Cell> </Table.Cell>
{isAdmin && <Table.Cell>{""}</Table.Cell>}
{isAdmin && <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.Cell>
<Table.Cell>{""}</Table.Cell> {isAdmin && <Table.Cell>{""}</Table.Cell>}
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
</Table.Row> </Table.Row>
)} )}
{chargePoints.map((cp) => ( {chargePoints.map((cp) => (
@@ -282,6 +318,7 @@ export default function ChargePointsPage() {
{cp.chargePointIdentifier} {cp.chargePointIdentifier}
</Link> </Link>
</Table.Cell> </Table.Cell>
{isAdmin && (
<Table.Cell> <Table.Cell>
{cp.chargePointVendor && cp.chargePointModel ? ( {cp.chargePointVendor && cp.chargePointModel ? (
`${cp.chargePointVendor} / ${cp.chargePointModel}` `${cp.chargePointVendor} / ${cp.chargePointModel}`
@@ -289,6 +326,8 @@ export default function ChargePointsPage() {
<span className="text-muted"></span> <span className="text-muted"></span>
)} )}
</Table.Cell> </Table.Cell>
)}
{isAdmin && (
<Table.Cell> <Table.Cell>
<Chip <Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"} color={registrationColorMap[cp.registrationStatus] ?? "warning"}
@@ -298,6 +337,7 @@ export default function ChargePointsPage() {
{cp.registrationStatus} {cp.registrationStatus}
</Chip> </Chip>
</Table.Cell> </Table.Cell>
)}
<Table.Cell className="tabular-nums"> <Table.Cell className="tabular-nums">
{cp.feePerKwh > 0 ? ( {cp.feePerKwh > 0 ? (
<span> <span>
@@ -322,7 +362,9 @@ export default function ChargePointsPage() {
{cp.connectors.length === 0 ? ( {cp.connectors.length === 0 ? (
<span className="text-muted text-sm"></span> <span className="text-muted text-sm"></span>
) : ( ) : (
[...cp.connectors].sort((a, b) => a.connectorId - b.connectorId).map((conn) => ( [...cp.connectors]
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => (
<div <div
key={conn.id} key={conn.id}
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1" className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1"
@@ -345,9 +387,15 @@ export default function ChargePointsPage() {
)} )}
</div> </div>
</Table.Cell> </Table.Cell>
{isAdmin && (
<Table.Cell> <Table.Cell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button isIconOnly size="sm" variant="tertiary" onPress={() => openEdit(cp)}> <Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => openEdit(cp)}
>
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
<Modal> <Modal>
@@ -394,6 +442,7 @@ export default function ChargePointsPage() {
</Modal> </Modal>
</div> </div>
</Table.Cell> </Table.Cell>
)}
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
@@ -403,4 +452,3 @@ export default function ChargePointsPage() {
</div> </div>
); );
} }

View File

@@ -23,6 +23,7 @@ import {
import { parseDate } from "@internationalized/date"; import { parseDate } from "@internationalized/date";
import { Pencil, Plus, TrashBin } from "@gravity-ui/icons"; import { Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api"; import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = { const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Accepted: "success", Accepted: "success",
@@ -245,6 +246,8 @@ function TagFormBody({
} }
export default function IdTagsPage() { export default function IdTagsPage() {
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [tags, setTags] = useState<IdTag[]>([]); const [tags, setTags] = useState<IdTag[]>([]);
const [users, setUsers] = useState<UserRow[]>([]); const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -252,6 +255,17 @@ export default function IdTagsPage() {
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingTag, setDeletingTag] = useState<string | null>(null); const [deletingTag, setDeletingTag] = useState<string | null>(null);
const [claiming, setClaiming] = useState(false);
const handleClaim = async () => {
setClaiming(true);
try {
await api.idTags.claim();
await load();
} finally {
setClaiming(false);
}
};
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
@@ -332,6 +346,7 @@ export default function IdTagsPage() {
<h1 className="text-xl font-semibold text-foreground"></h1> <h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {tags.length} </p> <p className="mt-0.5 text-sm text-muted"> {tags.length} </p>
</div> </div>
{isAdmin ? (
<Modal> <Modal>
<Button size="sm" variant="secondary" onPress={openCreate}> <Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" /> <Plus className="size-4" />
@@ -359,6 +374,12 @@ export default function IdTagsPage() {
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
) : (
<Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}>
<Plus className="size-4" />
{claiming ? <Spinner size="sm" /> : "申领储值卡"}
</Button>
)}
</div> </div>
<Table> <Table>
@@ -368,11 +389,11 @@ export default function IdTagsPage() {
<Table.Column isRowHeader></Table.Column> <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> {isAdmin && <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>
<Table.Column className="text-end"></Table.Column> {isAdmin && <Table.Column className="text-end"></Table.Column>}
</Table.Header> </Table.Header>
<Table.Body <Table.Body
renderEmptyState={() => ( renderEmptyState={() => (
@@ -396,6 +417,7 @@ export default function IdTagsPage() {
</Chip> </Chip>
</Table.Cell> </Table.Cell>
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell> <Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
{isAdmin && (
<Table.Cell className="text-sm"> <Table.Cell className="text-sm">
{owner ? ( {owner ? (
<span title={owner.email}> <span title={owner.email}>
@@ -405,6 +427,7 @@ export default function IdTagsPage() {
<span className="text-muted"></span> <span className="text-muted"></span>
)} )}
</Table.Cell> </Table.Cell>
)}
<Table.Cell> <Table.Cell>
{tag.expiryDate ? ( {tag.expiryDate ? (
new Date(tag.expiryDate).toLocaleDateString("zh-CN") new Date(tag.expiryDate).toLocaleDateString("zh-CN")
@@ -418,6 +441,7 @@ export default function IdTagsPage() {
<Table.Cell className="text-sm"> <Table.Cell className="text-sm">
{new Date(tag.createdAt).toLocaleString("zh-CN")} {new Date(tag.createdAt).toLocaleString("zh-CN")}
</Table.Cell> </Table.Cell>
{isAdmin && (
<Table.Cell> <Table.Cell>
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
{/* Edit button */} {/* Edit button */}
@@ -506,6 +530,7 @@ export default function IdTagsPage() {
</Modal> </Modal>
</div> </div>
</Table.Cell> </Table.Cell>
)}
</Table.Row> </Table.Row>
); );
})} })}

View File

@@ -1,8 +1,10 @@
import { Card } from "@heroui/react"; "use client";
import { Thunderbolt, PlugConnection, CreditCard, ChartColumn } from "@gravity-ui/icons";
import { api } from "@/lib/api";
export const dynamic = "force-dynamic"; import { useEffect, useState } from "react";
import { Card } from "@heroui/react";
import { Thunderbolt, PlugConnection, CreditCard, ChartColumn, TagDollar } from "@gravity-ui/icons";
import { useSession } from "@/lib/auth-client";
import { api, type Stats, type UserStats } from "@/lib/api";
type CardColor = "accent" | "success" | "warning" | "default"; type CardColor = "accent" | "success" | "warning" | "default";
@@ -55,9 +57,40 @@ function StatCard({
); );
} }
export default async function DashboardPage() { export default function DashboardPage() {
const stats = await api.stats.get().catch(() => null); const { data: sessionData, isPending } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [adminStats, setAdminStats] = useState<Stats | null>(null);
const [userStats, setUserStats] = useState<UserStats | null>(null);
useEffect(() => {
if (isPending) return;
api.stats
.get()
.then((data) => {
if ("todayEnergyWh" in data) {
setAdminStats(data);
return;
}
setUserStats(data);
})
.catch(() => {});
}, [isPending, isAdmin]);
if (isPending) {
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>
);
}
if (isAdmin) {
const stats = adminStats;
const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—"; const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—";
const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0); const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0);
@@ -124,3 +157,57 @@ export default async function DashboardPage() {
</div> </div>
); );
} }
// User view
const stats = userStats;
const totalYuan = stats ? (stats.totalBalance / 100).toFixed(2) : "—";
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">
{sessionData?.user?.name ?? sessionData?.user?.email}
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
title="我的储值卡"
value={stats?.totalIdTags ?? "—"}
icon={CreditCard}
color="accent"
footer={<span></span>}
/>
<StatCard
title="账户总余额"
value={`¥${totalYuan}`}
icon={TagDollar}
color="success"
footer={<span></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?.totalTransactions ?? "—"}
icon={ChartColumn}
color="default"
footer={<span></span>}
/>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react"; import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import { TrashBin } from "@gravity-ui/icons"; import { TrashBin } from "@gravity-ui/icons";
import { api, type PaginatedTransactions } from "@/lib/api"; import { api, type PaginatedTransactions } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
const LIMIT = 15; const LIMIT = 15;
@@ -18,6 +19,8 @@ function formatDuration(start: string, stop: string | null): string {
} }
export default function TransactionsPage() { export default function TransactionsPage() {
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [data, setData] = useState<PaginatedTransactions | null>(null); const [data, setData] = useState<PaginatedTransactions | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">("all"); const [status, setStatus] = useState<"all" | "active" | "completed">("all");
@@ -201,6 +204,7 @@ export default function TransactionsPage() {
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
)} )}
{isAdmin && (
<Modal> <Modal>
<Button <Button
isIconOnly isIconOnly
@@ -248,6 +252,7 @@ export default function TransactionsPage() {
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
)}
</div> </div>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>

View File

@@ -5,16 +5,17 @@ import { usePathname } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { CreditCard, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons' import { CreditCard, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons'
import SidebarFooter from '@/components/sidebar-footer' import SidebarFooter from '@/components/sidebar-footer'
import { useSession } from '@/lib/auth-client'
const navItems = [ const navItems = [
{ href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true }, { href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true, adminOnly: false },
{ href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection }, { href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection, adminOnly: false },
{ href: '/dashboard/transactions', label: '充电记录', icon: ListCheck }, { href: '/dashboard/transactions', label: '充电记录', icon: ListCheck, adminOnly: false },
{ href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard }, { href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard, adminOnly: false },
{ href: '/dashboard/users', label: '用户管理', icon: Person }, { href: '/dashboard/users', label: '用户管理', icon: Person, adminOnly: true },
] ]
function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) { function NavContent({ pathname, isAdmin, onNavigate }: { pathname: string; isAdmin: boolean; onNavigate?: () => void }) {
return ( return (
<> <>
{/* Logo */} {/* Logo */}
@@ -32,7 +33,7 @@ function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: (
<p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted"> <p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p> </p>
{navItems.map((item) => { {navItems.filter((item) => !item.adminOnly || isAdmin).map((item) => {
const isActive = item.exact const isActive = item.exact
? pathname === item.href ? pathname === item.href
: pathname === item.href || pathname.startsWith(item.href + '/') : pathname === item.href || pathname.startsWith(item.href + '/')
@@ -68,6 +69,8 @@ function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: (
export default function Sidebar() { export default function Sidebar() {
const pathname = usePathname() const pathname = usePathname()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { data: sessionData } = useSession()
const isAdmin = sessionData?.user?.role === "admin"
return ( return (
<> <>
@@ -112,12 +115,12 @@ export default function Sidebar() {
> >
<Xmark className="size-4" /> <Xmark className="size-4" />
</button> </button>
<NavContent pathname={pathname} onNavigate={() => setOpen(false)} /> <NavContent pathname={pathname} isAdmin={isAdmin} onNavigate={() => setOpen(false)} />
</aside> </aside>
{/* Desktop sidebar */} {/* Desktop sidebar */}
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface-secondary lg:flex"> <aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface-secondary lg:flex">
<NavContent pathname={pathname} /> <NavContent pathname={pathname} isAdmin={isAdmin} />
</aside> </aside>
</> </>
) )

View File

@@ -26,6 +26,13 @@ export type Stats = {
todayEnergyWh: number; todayEnergyWh: number;
}; };
export type UserStats = {
totalIdTags: number;
totalBalance: number;
activeTransactions: number;
totalTransactions: number;
};
export type ConnectorSummary = { export type ConnectorSummary = {
id: string; id: string;
connectorId: number; connectorId: number;
@@ -128,7 +135,7 @@ export type PaginatedTransactions = {
export const api = { export const api = {
stats: { stats: {
get: () => apiFetch<Stats>("/api/stats"), get: () => apiFetch<Stats | UserStats>("/api/stats"),
}, },
chargePoints: { chargePoints: {
list: () => apiFetch<ChargePoint[]>("/api/charge-points"), list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
@@ -178,6 +185,7 @@ export const api = {
idTags: { idTags: {
list: () => apiFetch<IdTag[]>("/api/id-tags"), list: () => apiFetch<IdTag[]>("/api/id-tags"),
get: (idTag: string) => apiFetch<IdTag>(`/api/id-tags/${idTag}`), get: (idTag: string) => apiFetch<IdTag>(`/api/id-tags/${idTag}`),
claim: () => apiFetch<IdTag>("/api/id-tags/claim", { method: "POST" }),
create: (data: { create: (data: {
idTag: string; idTag: string;
status?: string; status?: string;