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,44 +1,81 @@
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";
const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] = if (isAdmin) {
await Promise.all([ const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] =
// Total charge points await Promise.all([
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'`), db
// Active (in-progress) transactions .select({ count: sql<number>`count(*)::int` })
db .from(transaction)
.select({ count: sql<number>`count(*)::int` }) .where(isNull(transaction.stopTimestamp)),
.from(transaction) db.select({ count: sql<number>`count(*)::int` }).from(idTag),
.where(isNull(transaction.stopTimestamp)), db
// Total id tags .select({
db.select({ count: sql<number>`count(*)::int` }).from(idTag), total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
// Energy dispensed today (sum of stopMeterValue - startMeterValue for transactions ending today) })
db .from(transaction)
.select({ .where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`, ]);
})
.from(transaction) return c.json({
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`), totalChargePoints: totalChargePoints[0].count,
]); onlineChargePoints: onlineChargePoints[0].count,
activeTransactions: activeTransactions[0].count,
totalIdTags: totalIdTags[0].count,
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({ return c.json({
totalChargePoints: totalChargePoints[0].count, totalIdTags: userIdTags[0].count,
onlineChargePoints: onlineChargePoints[0].count, totalBalance: totalBalance[0].total,
activeTransactions: activeTransactions[0].count, activeTransactions: activeCount[0].count,
totalIdTags: totalIdTags[0].count, totalTransactions: totalTxCount[0].count,
todayEnergyWh: todayEnergy[0].total,
}); });
}); });

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>
<Button size="sm" variant="secondary" onPress={openEdit}> {isAdmin && (
<Pencil className="size-4" /> <Button size="sm" variant="secondary" onPress={openEdit}>
<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,91 +547,93 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</div> </div>
{/* Edit modal */} {/* Edit modal */}
<Modal {isAdmin && (
isOpen={editOpen} <Modal
onOpenChange={(open) => { isOpen={editOpen}
if (!editBusy) setEditOpen(open); onOpenChange={(open) => {
}} if (!editBusy) setEditOpen(open);
> }}
<Modal.Backdrop> >
<Modal.Container scroll="outside"> <Modal.Backdrop>
<Modal.Dialog className="sm:max-w-md"> <Modal.Container scroll="outside">
<Modal.CloseTrigger /> <Modal.Dialog className="sm:max-w-md">
<Modal.Header> <Modal.CloseTrigger />
<Modal.Heading></Modal.Heading> <Modal.Header>
</Modal.Header> <Modal.Heading></Modal.Heading>
<Modal.Body className="space-y-3"> </Modal.Header>
<div className="grid grid-cols-2 gap-3"> <Modal.Body className="space-y-3">
<TextField fullWidth> <div className="grid grid-cols-2 gap-3">
<Label className="text-sm font-medium"></Label> <TextField fullWidth>
<Input <Label className="text-sm font-medium"></Label>
placeholder="Unknown" <Input
value={editForm.chargePointVendor} placeholder="Unknown"
onChange={(e) => value={editForm.chargePointVendor}
setEditForm((f) => ({ ...f, chargePointVendor: e.target.value })) onChange={(e) =>
setEditForm((f) => ({ ...f, chargePointVendor: e.target.value }))
}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Unknown"
value={editForm.chargePointModel}
onChange={(e) =>
setEditForm((f) => ({ ...f, chargePointModel: e.target.value }))
}
/>
</TextField>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={editForm.registrationStatus}
onSelectionChange={(key) =>
setEditForm((f) => ({
...f,
registrationStatus: String(key) as EditForm["registrationStatus"],
}))
} }
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="Accepted">Accepted</ListBox.Item>
<ListBox.Item id="Pending">Pending</ListBox.Item>
<ListBox.Item id="Rejected">Rejected</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={editForm.feePerKwh}
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
/> />
</TextField> </TextField>
<TextField fullWidth> </Modal.Body>
<Label className="text-sm font-medium"></Label> <Modal.Footer className="flex justify-end gap-2">
<Input <Button variant="ghost" onPress={() => setEditOpen(false)}>
placeholder="Unknown"
value={editForm.chargePointModel} </Button>
onChange={(e) => <Button isDisabled={editBusy} onPress={handleEditSubmit}>
setEditForm((f) => ({ ...f, chargePointModel: e.target.value })) {editBusy ? <Spinner size="sm" /> : "保存"}
} </Button>
/> </Modal.Footer>
</TextField> </Modal.Dialog>
</div> </Modal.Container>
<div className="space-y-1.5"> </Modal.Backdrop>
<Label className="text-sm font-medium"></Label> </Modal>
<Select )}
fullWidth
selectedKey={editForm.registrationStatus}
onSelectionChange={(key) =>
setEditForm((f) => ({
...f,
registrationStatus: String(key) as EditForm["registrationStatus"],
}))
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="Accepted">Accepted</ListBox.Item>
<ListBox.Item id="Pending">Pending</ListBox.Item>
<ListBox.Item id="Rejected">Rejected</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={editForm.feePerKwh}
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
/>
</TextField>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button variant="ghost" onPress={() => setEditOpen(false)}>
</Button>
<Button isDisabled={editBusy} onPress={handleEditSubmit}>
{editBusy ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</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,114 +165,134 @@ 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>
<Button size="sm" variant="secondary" onPress={openCreate}> {isAdmin && (
<Plus className="size-4" /> <Button size="sm" variant="secondary" onPress={openCreate}>
<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.Backdrop> <>
<Modal.Container scroll="outside"> <Modal
<Modal.Dialog className="sm:max-w-md"> isOpen={formOpen}
<Modal.CloseTrigger /> onOpenChange={(open) => {
<Modal.Header> if (!formBusy) setFormOpen(open);
<Modal.Heading>{isEdit ? "编辑充电桩" : "新建充电桩"}</Modal.Heading> }}
</Modal.Header> >
<Modal.Body className="space-y-3"> <Modal.Backdrop>
<TextField fullWidth isRequired isReadOnly={isEdit}> <Modal.Container scroll="outside">
<Label className="text-sm font-medium"></Label> <Modal.Dialog className="sm:max-w-md">
<Input <Modal.CloseTrigger />
placeholder="CP001" <Modal.Header>
value={formData.chargePointIdentifier} <Modal.Heading>{isEdit ? "编辑充电桩" : "新建充电桩"}</Modal.Heading>
onChange={(e) => setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))} </Modal.Header>
/> <Modal.Body className="space-y-3">
</TextField> <TextField fullWidth isRequired isReadOnly={isEdit}>
<div className="grid grid-cols-2 gap-3"> <Label className="text-sm font-medium"></Label>
<TextField fullWidth> <Input
<Label className="text-sm font-medium"></Label> placeholder="CP001"
<Input value={formData.chargePointIdentifier}
placeholder="ABB" onChange={(e) =>
value={formData.chargePointVendor} setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))
onChange={(e) => setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))} }
/> />
</TextField> </TextField>
<TextField fullWidth> <div className="grid grid-cols-2 gap-3">
<Label className="text-sm font-medium"></Label> <TextField fullWidth>
<Input <Label className="text-sm font-medium"></Label>
placeholder="Terra AC" <Input
value={formData.chargePointModel} placeholder="ABB"
onChange={(e) => setFormData((f) => ({ ...f, chargePointModel: e.target.value }))} value={formData.chargePointVendor}
/> onChange={(e) =>
</TextField> setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))
</div> }
<div className="space-y-1.5"> />
<Label className="text-sm font-medium"></Label> </TextField>
<Select <TextField fullWidth>
fullWidth <Label className="text-sm font-medium"></Label>
selectedKey={formData.registrationStatus} <Input
onSelectionChange={(key) => placeholder="Terra AC"
setFormData((f) => ({ ...f, registrationStatus: String(key) as FormData["registrationStatus"] })) value={formData.chargePointModel}
} onChange={(e) =>
> setFormData((f) => ({ ...f, chargePointModel: e.target.value }))
<Select.Trigger> }
<Select.Value /> />
<Select.Indicator /> </TextField>
</Select.Trigger> </div>
<Select.Popover> <div className="space-y-1.5">
<ListBox> <Label className="text-sm font-medium"></Label>
<ListBox.Item id="Accepted">Accepted</ListBox.Item> <Select
<ListBox.Item id="Pending">Pending</ListBox.Item> fullWidth
<ListBox.Item id="Rejected">Rejected</ListBox.Item> selectedKey={formData.registrationStatus}
</ListBox> onSelectionChange={(key) =>
</Select.Popover> setFormData((f) => ({
</Select> ...f,
</div> registrationStatus: String(key) as FormData["registrationStatus"],
<TextField fullWidth> }))
<Label className="text-sm font-medium">/kWh</Label> }
<Input >
type="number" <Select.Trigger>
min="0" <Select.Value />
step="1" <Select.Indicator />
placeholder="0" </Select.Trigger>
value={formData.feePerKwh} <Select.Popover>
onChange={(e) => setFormData((f) => ({ ...f, feePerKwh: e.target.value }))} <ListBox>
/> <ListBox.Item id="Accepted">Accepted</ListBox.Item>
</TextField> <ListBox.Item id="Pending">Pending</ListBox.Item>
{!isEdit && ( <ListBox.Item id="Rejected">Rejected</ListBox.Item>
<p className="text-xs text-muted"> </ListBox>
Pending Accepted </Select.Popover>
</p> </Select>
)} </div>
</Modal.Body> <TextField fullWidth>
<Modal.Footer className="flex justify-end gap-2"> <Label className="text-sm font-medium">/kWh</Label>
<Button variant="ghost" onPress={() => setFormOpen(false)}> <Input
type="number"
</Button> min="0"
<Button step="1"
isDisabled={formBusy || !formData.chargePointIdentifier.trim()} placeholder="0"
onPress={handleSubmit} value={formData.feePerKwh}
> onChange={(e) => setFormData((f) => ({ ...f, feePerKwh: e.target.value }))}
{formBusy ? <Spinner size="sm" /> : isEdit ? "保存" : "创建"} />
</Button> </TextField>
</Modal.Footer> {!isEdit && (
</Modal.Dialog> <p className="text-xs text-muted">
</Modal.Container> Pending Accepted
</Modal.Backdrop> </p>
</Modal> )}
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button variant="ghost" onPress={() => setFormOpen(false)}>
</Button>
<Button
isDisabled={formBusy || !formData.chargePointIdentifier.trim()}
onPress={handleSubmit}
>
{formBusy ? <Spinner size="sm" /> : isEdit ? "保存" : "创建"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</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,22 +318,26 @@ export default function ChargePointsPage() {
{cp.chargePointIdentifier} {cp.chargePointIdentifier}
</Link> </Link>
</Table.Cell> </Table.Cell>
<Table.Cell> {isAdmin && (
{cp.chargePointVendor && cp.chargePointModel ? ( <Table.Cell>
`${cp.chargePointVendor} / ${cp.chargePointModel}` {cp.chargePointVendor && cp.chargePointModel ? (
) : ( `${cp.chargePointVendor} / ${cp.chargePointModel}`
<span className="text-muted"></span> ) : (
)} <span className="text-muted"></span>
</Table.Cell> )}
<Table.Cell> </Table.Cell>
<Chip )}
color={registrationColorMap[cp.registrationStatus] ?? "warning"} {isAdmin && (
size="sm" <Table.Cell>
variant="soft" <Chip
> color={registrationColorMap[cp.registrationStatus] ?? "warning"}
{cp.registrationStatus} size="sm"
</Chip> variant="soft"
</Table.Cell> >
{cp.registrationStatus}
</Chip>
</Table.Cell>
)}
<Table.Cell className="tabular-nums"> <Table.Cell className="tabular-nums">
{cp.feePerKwh > 0 ? ( {cp.feePerKwh > 0 ? (
<span> <span>
@@ -322,78 +362,87 @@ 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]
<div .sort((a, b) => a.connectorId - b.connectorId)
key={conn.id} .map((conn) => (
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1" <div
> key={conn.id}
<PlugConnection className="size-3 shrink-0 text-muted" /> className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1"
<span className="text-xs font-medium tabular-nums text-muted"> >
#{conn.connectorId} <PlugConnection className="size-3 shrink-0 text-muted" />
</span> <span className="text-xs font-medium tabular-nums text-muted">
<span className="h-3 w-px bg-border" /> #{conn.connectorId}
<span </span>
className={`size-1.5 shrink-0 rounded-full ${ <span className="h-3 w-px bg-border" />
statusDotClass[conn.status] ?? "bg-warning" <span
}`} className={`size-1.5 shrink-0 rounded-full ${
/> statusDotClass[conn.status] ?? "bg-warning"
<span className="text-xs text-foreground text-nowrap"> }`}
{statusLabelMap[conn.status] ?? conn.status} />
</span> <span className="text-xs text-foreground text-nowrap">
</div> {statusLabelMap[conn.status] ?? conn.status}
)) </span>
</div>
))
)} )}
</div> </div>
</Table.Cell> </Table.Cell>
<Table.Cell> {isAdmin && (
<div className="flex items-center gap-1"> <Table.Cell>
<Button isIconOnly size="sm" variant="tertiary" onPress={() => openEdit(cp)}> <div className="flex items-center gap-1">
<Pencil className="size-4" />
</Button>
<Modal>
<Button <Button
isIconOnly isIconOnly
size="sm" size="sm"
variant="danger-soft" variant="tertiary"
onPress={() => setDeleteTarget(cp)} onPress={() => openEdit(cp)}
> >
<TrashBin className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
<Modal.Backdrop> <Modal>
<Modal.Container scroll="outside"> <Button
<Modal.Dialog className="sm:max-w-96"> isIconOnly
<Modal.CloseTrigger /> size="sm"
<Modal.Header> variant="danger-soft"
<Modal.Heading></Modal.Heading> onPress={() => setDeleteTarget(cp)}
</Modal.Header> >
<Modal.Body> <TrashBin className="size-4" />
<p className="text-sm text-muted"> </Button>
{" "} <Modal.Backdrop>
<span className="font-mono font-medium text-foreground"> <Modal.Container scroll="outside">
{cp.chargePointIdentifier} <Modal.Dialog className="sm:max-w-96">
</span> <Modal.CloseTrigger />
<Modal.Header>
</p> <Modal.Heading></Modal.Heading>
</Modal.Body> </Modal.Header>
<Modal.Footer className="flex justify-end gap-2"> <Modal.Body>
<Button slot="close" variant="ghost"> <p className="text-sm text-muted">
{" "}
</Button> <span className="font-mono font-medium text-foreground">
<Button {cp.chargePointIdentifier}
slot="close" </span>
variant="danger"
isDisabled={deleting} </p>
onPress={handleDelete} </Modal.Body>
> <Modal.Footer className="flex justify-end gap-2">
{deleting ? <Spinner size="sm" /> : "确认删除"} <Button slot="close" variant="ghost">
</Button>
</Modal.Footer> </Button>
</Modal.Dialog> <Button
</Modal.Container> slot="close"
</Modal.Backdrop> variant="danger"
</Modal> isDisabled={deleting}
</div> onPress={handleDelete}
</Table.Cell> >
{deleting ? <Spinner size="sm" /> : "确认删除"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</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,11 +346,12 @@ 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>
<Modal> {isAdmin ? (
<Button size="sm" variant="secondary" onPress={openCreate}> <Modal>
<Plus className="size-4" /> <Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" />
</Button>
</Button>
<Modal.Backdrop> <Modal.Backdrop>
<Modal.Container scroll="outside"> <Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105"> <Modal.Dialog className="sm:max-w-105">
@@ -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,42 +57,134 @@ 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 todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—"; const [adminStats, setAdminStats] = useState<Stats | null>(null);
const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0); 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 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>
);
}
// User view
const stats = userStats;
const totalYuan = stats ? (stats.totalBalance / 100).toFixed(2) : "—";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<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"></p> <p className="mt-0.5 text-sm text-muted">
{sessionData?.user?.name ?? sessionData?.user?.email}
</p>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard <StatCard
title="充电桩总数" title="我的储值卡"
value={stats?.totalChargePoints ?? "—"} value={stats?.totalIdTags ?? "—"}
icon={PlugConnection} icon={CreditCard}
color="accent" color="accent"
footer={ footer={<span></span>}
<>
<StatusDot color="success" />
<span className="font-medium text-success">
{stats?.onlineChargePoints ?? 0} 线
</span>
<span className="text-border">·</span>
<span>{offlineCount} 线</span>
</>
}
/> />
<StatCard <StatCard
title="在线充电桩" title="账户总余额"
value={stats?.onlineChargePoints ?? "—"} value={`¥${totalYuan}`}
icon={PlugConnection} icon={TagDollar}
color="success" color="success"
footer={<span> 2 </span>} footer={<span></span>}
/> />
<StatCard <StatCard
title="进行中充电" title="进行中充电"
@@ -101,24 +195,17 @@ export default async function DashboardPage() {
<> <>
<StatusDot color={stats?.activeTransactions ? "success" : "muted"} /> <StatusDot color={stats?.activeTransactions ? "success" : "muted"} />
<span className={stats?.activeTransactions ? "font-medium text-success" : ""}> <span className={stats?.activeTransactions ? "font-medium text-success" : ""}>
{stats?.activeTransactions ? "活跃中" : "当前空闲"} {stats?.activeTransactions ? "充电中" : "当前空闲"}
</span> </span>
</> </>
} }
/> />
<StatCard <StatCard
title="储值卡总数" title="累计充电次数"
value={stats?.totalIdTags ?? "—"} value={stats?.totalTransactions ?? "—"}
icon={CreditCard}
color="default"
footer={<span></span>}
/>
<StatCard
title="今日充电量"
value={`${todayKwh} kWh`}
icon={ChartColumn} icon={ChartColumn}
color="accent" color="default"
footer={<span> 00:00 </span>} footer={<span></span>}
/> />
</div> </div>
</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;