diff --git a/apps/csms/src/routes/charge-points.ts b/apps/csms/src/routes/charge-points.ts
index 4d40a46..385dd39 100644
--- a/apps/csms/src/routes/charge-points.ts
+++ b/apps/csms/src/routes/charge-points.ts
@@ -2,14 +2,20 @@ import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js";
+import type { HonoEnv } from "@/types/hono.ts";
-const app = new Hono();
+const app = new Hono();
/** GET /api/charge-points — list all charge points with connectors */
app.get("/", async (c) => {
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)
const connectors = cps.length
@@ -37,6 +43,7 @@ app.get("/", async (c) => {
/** POST /api/charge-points — manually pre-register a charge point */
app.post("/", async (c) => {
+ if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const body = await c.req.json<{
chargePointIdentifier: string;
@@ -88,6 +95,7 @@ app.get("/:id", async (c) => {
/** PATCH /api/charge-points/:id — update charge point fields */
app.patch("/:id", async (c) => {
+ if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const id = c.req.param("id");
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) */
app.delete("/:id", async (c) => {
+ if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const id = c.req.param("id");
diff --git a/apps/csms/src/routes/id-tags.ts b/apps/csms/src/routes/id-tags.ts
index 0270f90..e715315 100644
--- a/apps/csms/src/routes/id-tags.ts
+++ b/apps/csms/src/routes/id-tags.ts
@@ -4,8 +4,9 @@ import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
+import type { HonoEnv } from "@/types/hono.ts";
-const app = new Hono();
+const app = new Hono();
const idTagSchema = z.object({
idTag: z.string().min(1).max(20),
@@ -18,9 +19,32 @@ const idTagSchema = z.object({
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 */
app.get("/", async (c) => {
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));
return c.json(tags);
});
@@ -34,31 +58,69 @@ app.get("/:id", async (c) => {
return c.json(tag);
});
-/** POST /api/id-tags */
-app.post("/", zValidator("json", idTagSchema), async (c) => {
+/** POST /api/id-tags — admin only, create arbitrary card */
+app.post("/", async (c) => {
+ if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
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
.insert(idTag)
.values({
- ...body,
- expiryDate: body.expiryDate ? new Date(body.expiryDate) : null,
+ ...parsed.data,
+ expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : null,
})
.returning();
return c.json(created, 201);
});
-/** PATCH /api/id-tags/:id */
-app.patch("/:id", zValidator("json", idTagUpdateSchema), async (c) => {
+/** POST /api/id-tags/claim — user claims a new card assigned to themselves */
+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 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
.update(idTag)
.set({
- ...body,
- expiryDate: body.expiryDate ? new Date(body.expiryDate) : undefined,
+ ...parsed.data,
+ expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : undefined,
updatedAt: new Date(),
})
.where(eq(idTag.idTag, tagId))
@@ -68,8 +130,9 @@ app.patch("/:id", zValidator("json", idTagUpdateSchema), async (c) => {
return c.json(updated);
});
-/** DELETE /api/id-tags/:id */
+/** DELETE /api/id-tags/:id — admin only */
app.delete("/:id", async (c) => {
+ if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const tagId = c.req.param("id");
diff --git a/apps/csms/src/routes/stats.ts b/apps/csms/src/routes/stats.ts
index d876d55..9037906 100644
--- a/apps/csms/src/routes/stats.ts
+++ b/apps/csms/src/routes/stats.ts
@@ -1,44 +1,81 @@
import { Hono } from "hono";
-import { isNull, sql } from "drizzle-orm";
+import { eq, isNull, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, transaction, idTag } from "@/db/schema.js";
+import type { HonoEnv } from "@/types/hono.ts";
-const app = new Hono();
+const app = new Hono();
app.get("/", async (c) => {
const db = useDrizzle();
+ const currentUser = c.get("user");
+ const isAdmin = currentUser?.role === "admin";
- const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] =
- await Promise.all([
- // Total charge points
- db.select({ count: sql`count(*)::int` }).from(chargePoint),
- // Online charge points (received heartbeat in last 2×heartbeat interval, default 120s)
- db
- .select({ count: sql`count(*)::int` })
- .from(chargePoint)
- .where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
- // Active (in-progress) transactions
- db
- .select({ count: sql`count(*)::int` })
- .from(transaction)
- .where(isNull(transaction.stopTimestamp)),
- // Total id tags
- db.select({ count: sql`count(*)::int` }).from(idTag),
- // Energy dispensed today (sum of stopMeterValue - startMeterValue for transactions ending today)
- db
- .select({
- total: sql`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
- })
- .from(transaction)
- .where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
- ]);
+ if (isAdmin) {
+ const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] =
+ await Promise.all([
+ db.select({ count: sql`count(*)::int` }).from(chargePoint),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(chargePoint)
+ .where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
+ db
+ .select({ count: sql`count(*)::int` })
+ .from(transaction)
+ .where(isNull(transaction.stopTimestamp)),
+ db.select({ count: sql`count(*)::int` }).from(idTag),
+ db
+ .select({
+ total: sql`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
+ })
+ .from(transaction)
+ .where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
+ ]);
+
+ return c.json({
+ totalChargePoints: totalChargePoints[0].count,
+ onlineChargePoints: onlineChargePoints[0].count,
+ activeTransactions: activeTransactions[0].count,
+ totalIdTags: totalIdTags[0].count,
+ todayEnergyWh: todayEnergy[0].total,
+ });
+ }
+
+ // 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`count(*)::int` })
+ .from(idTag)
+ .where(eq(idTag.userId, userId)),
+ // Sum of balances
+ db
+ .select({ total: sql`coalesce(sum(${idTag.balance}), 0)::int` })
+ .from(idTag)
+ .where(eq(idTag.userId, userId)),
+ // Active transactions for user's cards
+ db
+ .select({ count: sql`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`count(*)::int` })
+ .from(transaction)
+ .innerJoin(idTag, eq(transaction.idTag, idTag.idTag))
+ .where(eq(idTag.userId, userId)),
+ ]);
return c.json({
- totalChargePoints: totalChargePoints[0].count,
- onlineChargePoints: onlineChargePoints[0].count,
- activeTransactions: activeTransactions[0].count,
- totalIdTags: totalIdTags[0].count,
- todayEnergyWh: todayEnergy[0].total,
+ totalIdTags: userIdTags[0].count,
+ totalBalance: totalBalance[0].total,
+ activeTransactions: activeCount[0].count,
+ totalTransactions: totalTxCount[0].count,
});
});
diff --git a/apps/csms/src/routes/transactions.ts b/apps/csms/src/routes/transactions.ts
index e3aaeeb..42f6ed2 100644
--- a/apps/csms/src/routes/transactions.ts
+++ b/apps/csms/src/routes/transactions.ts
@@ -4,10 +4,11 @@ import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
+import type { HonoEnv } from "@/types/hono.ts";
-const app = new Hono();
+const app = new Hono();
-/** 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) => {
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)));
@@ -16,6 +17,8 @@ app.get("/", async (c) => {
const offset = (page - 1) * limit;
const db = useDrizzle();
+ const currentUser = c.get("user");
+ const isAdmin = currentUser?.role === "admin";
const statusCondition =
status === "active"
@@ -24,11 +27,15 @@ app.get("/", async (c) => {
? isNotNull(transaction.stopTimestamp)
: undefined;
- const whereClause = chargePointId
- ? statusCondition
- ? and(statusCondition, eq(transaction.chargePointId, chargePointId))
- : eq(transaction.chargePointId, chargePointId)
- : statusCondition;
+ // For non-admin users, restrict to transactions matching their id-tags
+ const userCondition =
+ !isAdmin && currentUser
+ ? sql`${transaction.idTag} in (select id_tag from id_tag where user_id = ${currentUser.id})`
+ : undefined;
+
+ const cpCondition = chargePointId ? eq(transaction.chargePointId, chargePointId) : undefined;
+
+ const whereClause = and(statusCondition, userCondition, cpCondition);
const [{ total }] = await db
.select({ total: sql`count(*)::int` })
@@ -174,6 +181,7 @@ app.post("/:id/stop", async (c) => {
/** DELETE /api/transactions/:id — delete a transaction record */
app.delete("/:id", async (c) => {
+ if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const id = Number(c.req.param("id"));
diff --git a/apps/web/app/dashboard/charge-points/[id]/page.tsx b/apps/web/app/dashboard/charge-points/[id]/page.tsx
index a47a72f..06bebf2 100644
--- a/apps/web/app/dashboard/charge-points/[id]/page.tsx
+++ b/apps/web/app/dashboard/charge-points/[id]/page.tsx
@@ -17,6 +17,7 @@ import {
} from "@heroui/react";
import { ArrowLeft, Pencil, PlugConnection } from "@gravity-ui/icons";
import { api, type ChargePointDetail, type PaginatedTransactions } from "@/lib/api";
+import { useSession } from "@/lib/auth-client";
// ── Status maps ────────────────────────────────────────────────────────────
@@ -182,6 +183,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
cp?.lastHeartbeatAt != null &&
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 ──────────────────────────────────────────
if (loading) {
@@ -249,15 +253,18 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
)}
-
+ {isAdmin && (
+
+ )}
{/* Info grid */}
- {/* Device info */}
+ {/* Device info — admin only */}
+ {isAdmin && (
设备信息
@@ -280,8 +287,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
))}
+ )}
- {/* Operation info */}
+ {/* Operation info — admin only */}
+ {isAdmin && (
运行配置
@@ -354,6 +363,38 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
+ )}
+
+ {/* Fee info — user only */}
+ {!isAdmin && (
+
+
电价信息
+
+
+
- 单位电价
+ -
+ {cp.feePerKwh > 0 ? (
+
+ ¥{(cp.feePerKwh / 100).toFixed(2)}
+ /kWh
+
+ ) : (
+ 免费
+ )}
+
+
+
+
- 充电桥状态
+
-
+
+
+ {isOnline ? "在线" : "离线"}
+
+
+
+
+
+ )}
{/* Connectors */}
@@ -506,91 +547,93 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{/* Edit modal */}
- {
- if (!editBusy) setEditOpen(open);
- }}
- >
-
-
-
-
-
- 编辑充电桩
-
-
-
-
-
-
-
-
-
- setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
- />
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ )}
);
}
diff --git a/apps/web/app/dashboard/charge-points/page.tsx b/apps/web/app/dashboard/charge-points/page.tsx
index e27d13a..3e07c57 100644
--- a/apps/web/app/dashboard/charge-points/page.tsx
+++ b/apps/web/app/dashboard/charge-points/page.tsx
@@ -1,11 +1,22 @@
"use client";
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 Link from "next/link";
import { api, type ChargePoint } from "@/lib/api";
-
+import { useSession } from "@/lib/auth-client";
const statusLabelMap: Record = {
Available: "空闲中",
@@ -109,7 +120,9 @@ export default function ChargePointsPage() {
feePerKwh: fee,
});
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 {
// Create
@@ -142,6 +155,9 @@ export default function ChargePointsPage() {
const isEdit = formTarget !== null;
+ const { data: sessionData } = useSession();
+ const isAdmin = sessionData?.user?.role === "admin";
+
return (
@@ -149,114 +165,134 @@ export default function ChargePointsPage() {
充电桩管理
共 {chargePoints.length} 台设备
-
+ {isAdmin && (
+
+ )}
- {/* Create / Edit modal */}
- { if (!formBusy) setFormOpen(open); }}>
-
-
-
-
-
- {isEdit ? "编辑充电桩" : "新建充电桩"}
-
-
-
-
- setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))}
- />
-
-
-
-
- setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))}
- />
-
-
-
- setFormData((f) => ({ ...f, chargePointModel: e.target.value }))}
- />
-
-
-
-
-
-
-
-
- setFormData((f) => ({ ...f, feePerKwh: e.target.value }))}
- />
-
- {!isEdit && (
-
- 手动创建的充电桩默认注册状态为 Pending,需手动改为 Accepted 后才可正常充电。
-
- )}
-
-
-
-
-
-
-
-
-
+ {/* Create / Edit modal — admin only */}
+ {isAdmin && (
+ <>
+ {
+ if (!formBusy) setFormOpen(open);
+ }}
+ >
+
+
+
+
+
+ {isEdit ? "编辑充电桩" : "新建充电桩"}
+
+
+
+
+
+ setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))
+ }
+ />
+
+
+
+
+
+ setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))
+ }
+ />
+
+
+
+
+ setFormData((f) => ({ ...f, chargePointModel: e.target.value }))
+ }
+ />
+
+
+
+
+
+
+
+
+ setFormData((f) => ({ ...f, feePerKwh: e.target.value }))}
+ />
+
+ {!isEdit && (
+
+ 手动创建的充电桩默认注册状态为 Pending,需手动改为 Accepted 后才可正常充电。
+
+ )}
+
+
+
+
+
+
+
+
+
+ >
+ )}
标识符
- 品牌 / 型号
- 注册状态
+ {isAdmin && 品牌 / 型号}
+ {isAdmin && 注册状态}
电价(分/kWh)
最后心跳
接口状态
- {""}
+ {isAdmin && {""}}
{chargePoints.length === 0 && (
@@ -264,12 +300,12 @@ export default function ChargePointsPage() {
暂无设备
+ {isAdmin && {""}}
+ {isAdmin && {""}}
{""}
{""}
{""}
- {""}
- {""}
- {""}
+ {isAdmin && {""}}
)}
{chargePoints.map((cp) => (
@@ -282,22 +318,26 @@ export default function ChargePointsPage() {
{cp.chargePointIdentifier}
-
- {cp.chargePointVendor && cp.chargePointModel ? (
- `${cp.chargePointVendor} / ${cp.chargePointModel}`
- ) : (
- —
- )}
-
-
-
- {cp.registrationStatus}
-
-
+ {isAdmin && (
+
+ {cp.chargePointVendor && cp.chargePointModel ? (
+ `${cp.chargePointVendor} / ${cp.chargePointModel}`
+ ) : (
+ —
+ )}
+
+ )}
+ {isAdmin && (
+
+
+ {cp.registrationStatus}
+
+
+ )}
{cp.feePerKwh > 0 ? (
@@ -322,78 +362,87 @@ export default function ChargePointsPage() {
{cp.connectors.length === 0 ? (
—
) : (
- [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId).map((conn) => (
-
-
-
- #{conn.connectorId}
-
-
-
-
- {statusLabelMap[conn.status] ?? conn.status}
-
-
- ))
+ [...cp.connectors]
+ .sort((a, b) => a.connectorId - b.connectorId)
+ .map((conn) => (
+
+
+
+ #{conn.connectorId}
+
+
+
+
+ {statusLabelMap[conn.status] ?? conn.status}
+
+
+ ))
)}
-
-
-
-
+ {isAdmin && (
+
+
-
-
-
-
-
- 确认删除充电桩
-
-
-
- 将删除充电桩{" "}
-
- {cp.chargePointIdentifier}
-
- 及其所有连接器和充电记录,此操作不可恢复。
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ 确认删除充电桩
+
+
+
+ 将删除充电桩{" "}
+
+ {cp.chargePointIdentifier}
+
+ 及其所有连接器和充电记录,此操作不可恢复。
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
))}
@@ -403,4 +452,3 @@ export default function ChargePointsPage() {
);
}
-
diff --git a/apps/web/app/dashboard/id-tags/page.tsx b/apps/web/app/dashboard/id-tags/page.tsx
index a7347e2..cd42285 100644
--- a/apps/web/app/dashboard/id-tags/page.tsx
+++ b/apps/web/app/dashboard/id-tags/page.tsx
@@ -23,6 +23,7 @@ import {
import { parseDate } from "@internationalized/date";
import { Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
+import { useSession } from "@/lib/auth-client";
const statusColorMap: Record = {
Accepted: "success",
@@ -245,6 +246,8 @@ function TagFormBody({
}
export default function IdTagsPage() {
+ const { data: sessionData } = useSession();
+ const isAdmin = sessionData?.user?.role === "admin";
const [tags, setTags] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -252,6 +255,17 @@ export default function IdTagsPage() {
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [deletingTag, setDeletingTag] = useState(null);
+ const [claiming, setClaiming] = useState(false);
+
+ const handleClaim = async () => {
+ setClaiming(true);
+ try {
+ await api.idTags.claim();
+ await load();
+ } finally {
+ setClaiming(false);
+ }
+ };
const load = async () => {
setLoading(true);
@@ -332,11 +346,12 @@ export default function IdTagsPage() {
储值卡管理
共 {tags.length} 张
-
-
+ {isAdmin ? (
+
+
@@ -359,6 +374,12 @@ export default function IdTagsPage() {
+ ) : (
+
+ )}
@@ -368,11 +389,11 @@ export default function IdTagsPage() {
卡号
状态
余额
- 关联用户
+ {isAdmin && 关联用户}
有效期
父卡号
创建时间
- 操作
+ {isAdmin && 操作}
(
@@ -396,6 +417,7 @@ export default function IdTagsPage() {
¥{fenToYuan(tag.balance)}
+ {isAdmin && (
{owner ? (
@@ -405,6 +427,7 @@ export default function IdTagsPage() {
—
)}
+ )}
{tag.expiryDate ? (
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
@@ -418,6 +441,7 @@ export default function IdTagsPage() {
{new Date(tag.createdAt).toLocaleString("zh-CN")}
+ {isAdmin && (
{/* Edit button */}
@@ -506,6 +530,7 @@ export default function IdTagsPage() {
+ )}
);
})}
diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx
index 652bd8b..dc73ffe 100644
--- a/apps/web/app/dashboard/page.tsx
+++ b/apps/web/app/dashboard/page.tsx
@@ -1,8 +1,10 @@
-import { Card } from "@heroui/react";
-import { Thunderbolt, PlugConnection, CreditCard, ChartColumn } from "@gravity-ui/icons";
-import { api } from "@/lib/api";
+"use client";
-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";
@@ -55,42 +57,134 @@ function StatCard({
);
}
-export default async function DashboardPage() {
- const stats = await api.stats.get().catch(() => null);
+export default function DashboardPage() {
+ const { data: sessionData, isPending } = useSession();
+ const isAdmin = sessionData?.user?.role === "admin";
- const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—";
- const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0);
+ const [adminStats, setAdminStats] = useState(null);
+ const [userStats, setUserStats] = useState(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 (
+
+ );
+ }
+
+ if (isAdmin) {
+ const stats = adminStats;
+ const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—";
+ const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0);
+
+ return (
+
+
+
+
+
+
+
+ {stats?.onlineChargePoints ?? 0} 在线
+
+ ·
+ {offlineCount} 离线
+ >
+ }
+ />
+ 最近 2 分钟有心跳}
+ />
+
+
+
+ {stats?.activeTransactions ? "活跃中" : "当前空闲"}
+
+ >
+ }
+ />
+ 已注册卡片总量}
+ />
+ 当日 00:00 起累计}
+ />
+
+
+ );
+ }
+
+ // User view
+ const stats = userStats;
+ const totalYuan = stats ? (stats.totalBalance / 100).toFixed(2) : "—";
return (
概览
-
实时运营状态
+
+ {sessionData?.user?.name ?? sessionData?.user?.email} 的账户概览
+
-
+
-
-
- {stats?.onlineChargePoints ?? 0} 在线
-
- ·
- {offlineCount} 离线
- >
- }
+ footer={已绑定的储值卡数量}
/>
最近 2 分钟有心跳}
+ footer={所有储值卡余额合计}
/>
- {stats?.activeTransactions ? "活跃中" : "当前空闲"}
+ {stats?.activeTransactions ? "充电中" : "当前空闲"}
>
}
/>
已注册卡片总量}
- />
- 当日 00:00 起累计}
+ color="default"
+ footer={历史总交易笔数}
/>
diff --git a/apps/web/app/dashboard/transactions/page.tsx b/apps/web/app/dashboard/transactions/page.tsx
index 4489902..6754324 100644
--- a/apps/web/app/dashboard/transactions/page.tsx
+++ b/apps/web/app/dashboard/transactions/page.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import { TrashBin } from "@gravity-ui/icons";
import { api, type PaginatedTransactions } from "@/lib/api";
+import { useSession } from "@/lib/auth-client";
const LIMIT = 15;
@@ -18,6 +19,8 @@ function formatDuration(start: string, stop: string | null): string {
}
export default function TransactionsPage() {
+ const { data: sessionData } = useSession();
+ const isAdmin = sessionData?.user?.role === "admin";
const [data, setData] = useState
(null);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
@@ -201,6 +204,7 @@ export default function TransactionsPage() {
)}
+ {isAdmin && (
+ )}
diff --git a/apps/web/components/sidebar.tsx b/apps/web/components/sidebar.tsx
index 2f19677..bb2c220 100644
--- a/apps/web/components/sidebar.tsx
+++ b/apps/web/components/sidebar.tsx
@@ -5,16 +5,17 @@ import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { CreditCard, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons'
import SidebarFooter from '@/components/sidebar-footer'
+import { useSession } from '@/lib/auth-client'
const navItems = [
- { href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true },
- { href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection },
- { href: '/dashboard/transactions', label: '充电记录', icon: ListCheck },
- { href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard },
- { href: '/dashboard/users', label: '用户管理', icon: Person },
+ { href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true, adminOnly: false },
+ { href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection, adminOnly: false },
+ { href: '/dashboard/transactions', label: '充电记录', icon: ListCheck, adminOnly: false },
+ { href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard, adminOnly: false },
+ { 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 (
<>
{/* Logo */}
@@ -32,7 +33,7 @@ function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: (
管理
- {navItems.map((item) => {
+ {navItems.filter((item) => !item.adminOnly || isAdmin).map((item) => {
const isActive = item.exact
? pathname === item.href
: pathname === item.href || pathname.startsWith(item.href + '/')
@@ -68,6 +69,8 @@ function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: (
export default function Sidebar() {
const pathname = usePathname()
const [open, setOpen] = useState(false)
+ const { data: sessionData } = useSession()
+ const isAdmin = sessionData?.user?.role === "admin"
return (
<>
@@ -112,12 +115,12 @@ export default function Sidebar() {
>
- setOpen(false)} />
+ setOpen(false)} />
{/* Desktop sidebar */}
>
)
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts
index 985dd7d..9830f26 100644
--- a/apps/web/lib/api.ts
+++ b/apps/web/lib/api.ts
@@ -26,6 +26,13 @@ export type Stats = {
todayEnergyWh: number;
};
+export type UserStats = {
+ totalIdTags: number;
+ totalBalance: number;
+ activeTransactions: number;
+ totalTransactions: number;
+};
+
export type ConnectorSummary = {
id: string;
connectorId: number;
@@ -128,7 +135,7 @@ export type PaginatedTransactions = {
export const api = {
stats: {
- get: () => apiFetch("/api/stats"),
+ get: () => apiFetch("/api/stats"),
},
chargePoints: {
list: () => apiFetch("/api/charge-points"),
@@ -178,6 +185,7 @@ export const api = {
idTags: {
list: () => apiFetch("/api/id-tags"),
get: (idTag: string) => apiFetch(`/api/id-tags/${idTag}`),
+ claim: () => apiFetch("/api/id-tags/claim", { method: "POST" }),
create: (data: {
idTag: string;
status?: string;