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, chargePointVendor: e.target.value })) + {isAdmin && ( + { + if (!editBusy) setEditOpen(open); + }} + > + + + + + + 编辑充电桩 + + +
+ + + + setEditForm((f) => ({ ...f, chargePointVendor: e.target.value })) + } + /> + + + + + setEditForm((f) => ({ ...f, chargePointModel: e.target.value })) + } + /> + +
+
+ + +
+ + + setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))} /> - - - - setEditForm((f) => ({ ...f, chargePointModel: e.target.value })) - } - /> - -
-
- - -
- - - 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;