feat: RBAC controlling
This commit is contained in:
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -123,4 +156,58 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user