feat(csms): 充电桩添加 deviceName 字段,区别于 identifier 用于区分设备

This commit is contained in:
2026-03-16 13:43:46 +08:00
parent 0118dd2e15
commit 654a2a66d9
14 changed files with 2095 additions and 40 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "charge_point" ADD COLUMN "device_name" varchar(100);

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1773307380017, "when": 1773307380017,
"tag": "0003_milky_supreme_intelligence", "tag": "0003_milky_supreme_intelligence",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1773639782622,
"tag": "0004_nervous_frog_thor",
"breakpoints": true
} }
] ]
} }

View File

@@ -71,6 +71,11 @@ export const chargePoint = pgTable('charge_point', {
* 交易结束时按实际用电量从储值卡扣费fee = ceil(energyWh * feePerKwh / 1000) * 交易结束时按实际用电量从储值卡扣费fee = ceil(energyWh * feePerKwh / 1000)
* 默认为 0即不计费。仅在 pricingMode = 'fixed' 时生效。 * 默认为 0即不计费。仅在 pricingMode = 'fixed' 时生效。
*/ */
/**
* 设备名称(系统内部维护,不会被设备上报信息覆盖)
* 供运营人员标记,例如"1号楼A区01号桩"
*/
deviceName: varchar('device_name', { length: 100 }),
feePerKwh: integer('fee_per_kwh').notNull().default(0), feePerKwh: integer('fee_per_kwh').notNull().default(0),
/** /**
* 计费模式 * 计费模式

View File

@@ -71,6 +71,7 @@ app.post("/", async (c) => {
registrationStatus?: "Accepted" | "Pending" | "Rejected"; registrationStatus?: "Accepted" | "Pending" | "Rejected";
feePerKwh?: number; feePerKwh?: number;
pricingMode?: "fixed" | "tou"; pricingMode?: "fixed" | "tou";
deviceName?: string;
}>(); }>();
if (!body.chargePointIdentifier?.trim()) { if (!body.chargePointIdentifier?.trim()) {
@@ -93,6 +94,7 @@ app.post("/", async (c) => {
registrationStatus: body.registrationStatus ?? "Pending", registrationStatus: body.registrationStatus ?? "Pending",
feePerKwh: body.feePerKwh ?? 0, feePerKwh: body.feePerKwh ?? 0,
pricingMode: body.pricingMode ?? "fixed", pricingMode: body.pricingMode ?? "fixed",
deviceName: body.deviceName?.trim() || null,
}) })
.returning() .returning()
.catch((err: Error) => { .catch((err: Error) => {
@@ -142,6 +144,7 @@ app.patch("/:id", async (c) => {
registrationStatus?: string; registrationStatus?: string;
chargePointVendor?: string; chargePointVendor?: string;
chargePointModel?: string; chargePointModel?: string;
deviceName?: string | null;
}>(); }>();
const set: { const set: {
@@ -150,6 +153,7 @@ app.patch("/:id", async (c) => {
registrationStatus?: "Accepted" | "Pending" | "Rejected"; registrationStatus?: "Accepted" | "Pending" | "Rejected";
chargePointVendor?: string; chargePointVendor?: string;
chargePointModel?: string; chargePointModel?: string;
deviceName?: string | null;
updatedAt: Date; updatedAt: Date;
} = { updatedAt: dayjs().toDate() }; } = { updatedAt: dayjs().toDate() };
@@ -167,6 +171,7 @@ app.patch("/:id", async (c) => {
} }
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown"; if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown";
if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown"; if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown";
if ("deviceName" in body) set.deviceName = body.deviceName?.trim() || null;
if (body.pricingMode !== undefined) { if (body.pricingMode !== undefined) {
if (!['fixed', 'tou'].includes(body.pricingMode)) { if (!['fixed', 'tou'].includes(body.pricingMode)) {
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400); return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);

View File

@@ -144,6 +144,7 @@ app.get("/", async (c) => {
.select({ .select({
transaction, transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier, chargePointIdentifier: chargePoint.chargePointIdentifier,
chargePointDeviceName: chargePoint.deviceName,
feePerKwh: chargePoint.feePerKwh, feePerKwh: chargePoint.feePerKwh,
pricingMode: chargePoint.pricingMode, pricingMode: chargePoint.pricingMode,
connectorNumber: connector.connectorId, connectorNumber: connector.connectorId,
@@ -217,6 +218,7 @@ app.get("/", async (c) => {
return { return {
...r.transaction, ...r.transaction,
chargePointIdentifier: r.chargePointIdentifier, chargePointIdentifier: r.chargePointIdentifier,
chargePointDeviceName: r.chargePointDeviceName,
connectorNumber: r.connectorNumber, connectorNumber: r.connectorNumber,
idTagUserId: r.idTagUserId, idTagUserId: r.idTagUserId,
idTagUserName: r.idTagUserName, idTagUserName: r.idTagUserName,
@@ -243,6 +245,7 @@ app.get("/:id", async (c) => {
.select({ .select({
transaction, transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier, chargePointIdentifier: chargePoint.chargePointIdentifier,
chargePointDeviceName: chargePoint.deviceName,
connectorNumber: connector.connectorId, connectorNumber: connector.connectorId,
feePerKwh: chargePoint.feePerKwh, feePerKwh: chargePoint.feePerKwh,
pricingMode: chargePoint.pricingMode, pricingMode: chargePoint.pricingMode,
@@ -294,6 +297,7 @@ app.get("/:id", async (c) => {
return c.json({ return c.json({
...row.transaction, ...row.transaction,
chargePointIdentifier: row.chargePointIdentifier, chargePointIdentifier: row.chargePointIdentifier,
chargePointDeviceName: row.chargePointDeviceName,
connectorNumber: row.connectorNumber, connectorNumber: row.connectorNumber,
energyWh: energyWh:
row.transaction.stopMeterValue != null row.transaction.stopMeterValue != null

View File

@@ -77,6 +77,7 @@ function relativeTime(iso: string): string {
// ── Edit form type ───────────────────────────────────────────────────────── // ── Edit form type ─────────────────────────────────────────────────────────
type EditForm = { type EditForm = {
deviceName: string;
chargePointVendor: string; chargePointVendor: string;
chargePointModel: string; chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected"; registrationStatus: "Accepted" | "Pending" | "Rejected";
@@ -96,6 +97,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editBusy, setEditBusy] = useState(false); const [editBusy, setEditBusy] = useState(false);
const [editForm, setEditForm] = useState<EditForm>({ const [editForm, setEditForm] = useState<EditForm>({
deviceName: "",
chargePointVendor: "", chargePointVendor: "",
chargePointModel: "", chargePointModel: "",
registrationStatus: "Pending", registrationStatus: "Pending",
@@ -122,6 +124,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
const openEdit = () => { const openEdit = () => {
if (!cp) return; if (!cp) return;
setEditForm({ setEditForm({
deviceName: cp.deviceName ?? "",
chargePointVendor: cp.chargePointVendor ?? "", chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "", chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as EditForm["registrationStatus"], registrationStatus: cp.registrationStatus as EditForm["registrationStatus"],
@@ -142,6 +145,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
registrationStatus: editForm.registrationStatus, registrationStatus: editForm.registrationStatus,
pricingMode: editForm.pricingMode, pricingMode: editForm.pricingMode,
feePerKwh: editForm.pricingMode === "fixed" ? fee : 0, feePerKwh: editForm.pricingMode === "fixed" ? fee : 0,
deviceName: editForm.deviceName.trim() || null,
}); });
await cpQuery.refetch(); await cpQuery.refetch();
setEditOpen(false); setEditOpen(false);
@@ -202,9 +206,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h1 className="font-mono text-2xl font-semibold text-foreground"> <h1 className="text-2xl font-semibold text-foreground">
{cp.chargePointIdentifier} {cp.deviceName ?? <span className="font-mono">{cp.chargePointIdentifier}</span>}
</h1> </h1>
{isAdmin && cp.deviceName && (
<span className="font-mono text-sm text-muted">{cp.chargePointIdentifier}</span>
)}
<Chip <Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"} color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm" size="sm"
@@ -561,6 +568,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Modal.Heading></Modal.Heading> <Modal.Heading></Modal.Heading>
</Modal.Header> </Modal.Header>
<Modal.Body className="space-y-3"> <Modal.Body className="space-y-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="1号楼A区01号桩"
value={editForm.deviceName}
onChange={(e) =>
setEditForm((f) => ({ ...f, deviceName: e.target.value }))
}
/>
</TextField>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<TextField fullWidth> <TextField fullWidth>
<Label className="text-sm font-medium"></Label> <Label className="text-sm font-medium"></Label>

View File

@@ -97,6 +97,7 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
type FormData = { type FormData = {
chargePointIdentifier: string; chargePointIdentifier: string;
deviceName: string;
chargePointVendor: string; chargePointVendor: string;
chargePointModel: string; chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected"; registrationStatus: "Accepted" | "Pending" | "Rejected";
@@ -106,6 +107,7 @@ type FormData = {
const EMPTY_FORM: FormData = { const EMPTY_FORM: FormData = {
chargePointIdentifier: "", chargePointIdentifier: "",
deviceName: "",
chargePointVendor: "", chargePointVendor: "",
chargePointModel: "", chargePointModel: "",
registrationStatus: "Pending", registrationStatus: "Pending",
@@ -141,6 +143,7 @@ export default function ChargePointsPage() {
setFormTarget(cp); setFormTarget(cp);
setFormData({ setFormData({
chargePointIdentifier: cp.chargePointIdentifier, chargePointIdentifier: cp.chargePointIdentifier,
deviceName: cp.deviceName ?? "",
chargePointVendor: cp.chargePointVendor ?? "", chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "", chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as FormData["registrationStatus"], registrationStatus: cp.registrationStatus as FormData["registrationStatus"],
@@ -163,6 +166,7 @@ export default function ChargePointsPage() {
registrationStatus: formData.registrationStatus, registrationStatus: formData.registrationStatus,
pricingMode: formData.pricingMode, pricingMode: formData.pricingMode,
feePerKwh: formData.pricingMode === "fixed" ? fee : 0, feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || null,
}); });
} else { } else {
// Create // Create
@@ -173,6 +177,7 @@ export default function ChargePointsPage() {
registrationStatus: formData.registrationStatus, registrationStatus: formData.registrationStatus,
pricingMode: formData.pricingMode, pricingMode: formData.pricingMode,
feePerKwh: formData.pricingMode === "fixed" ? fee : 0, feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || undefined,
}); });
} }
await refetchList(); await refetchList();
@@ -258,8 +263,18 @@ export default function ChargePointsPage() {
} }
/> />
</TextField> </TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="1号楼A区01号桩"
value={formData.deviceName}
onChange={(e) =>
setFormData((f) => ({ ...f, deviceName: e.target.value }))
}
/>
</TextField>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<TextField fullWidth> <TextField fullWidth isReadOnly={isEdit}>
<Label className="text-sm font-medium"></Label> <Label className="text-sm font-medium"></Label>
<Input <Input
placeholder="ABB" placeholder="ABB"
@@ -269,7 +284,7 @@ export default function ChargePointsPage() {
} }
/> />
</TextField> </TextField>
<TextField fullWidth> <TextField fullWidth isReadOnly={isEdit}>
<Label className="text-sm font-medium"></Label> <Label className="text-sm font-medium"></Label>
<Input <Input
placeholder="Terra AC" placeholder="Terra AC"
@@ -381,7 +396,9 @@ export default function ChargePointsPage() {
<Modal.Dialog className="sm:max-w-lg"> <Modal.Dialog className="sm:max-w-lg">
<Modal.CloseTrigger /> <Modal.CloseTrigger />
<Modal.Header> <Modal.Header>
<Modal.Heading>{qrTarget?.chargePointIdentifier} </Modal.Heading> <Modal.Heading>
{qrTarget?.deviceName ?? qrTarget?.chargePointIdentifier}
</Modal.Heading>
</Modal.Header> </Modal.Header>
<Modal.Body className="space-y-4"> <Modal.Body className="space-y-4">
<p className="text-sm text-muted"> <p className="text-sm text-muted">
@@ -431,7 +448,7 @@ export default function ChargePointsPage() {
<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>
{isAdmin && <Table.Column> / </Table.Column>} {isAdmin && <Table.Column> / </Table.Column>}
{isAdmin && <Table.Column></Table.Column>} {isAdmin && <Table.Column></Table.Column>}
<Table.Column></Table.Column> <Table.Column></Table.Column>
@@ -469,12 +486,19 @@ export default function ChargePointsPage() {
: "bg-gray-300" : "bg-gray-300"
}`} }`}
/> />
<Link <div className="flex flex-col">
href={`/dashboard/charge-points/${cp.id}`} <Link
className="font-medium text-accent" href={`/dashboard/charge-points/${cp.id}`}
> className="font-medium text-accent"
{cp.chargePointIdentifier} >
</Link> {cp.deviceName ?? cp.chargePointIdentifier}
</Link>
{isAdmin && cp.deviceName && (
<span className="font-mono text-xs text-muted">
{cp.chargePointIdentifier}
</span>
)}
</div>
</div> </div>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content placement="start"> <Tooltip.Content placement="start">
@@ -594,9 +618,14 @@ export default function ChargePointsPage() {
<Modal.Body> <Modal.Body>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
{" "} {" "}
<span className="font-mono font-medium text-foreground"> <span className="font-medium text-foreground">
{cp.chargePointIdentifier} {cp.deviceName ?? cp.chargePointIdentifier}
</span> </span>
{cp.deviceName && (
<span className="font-mono ml-1 text-xs text-muted">
({cp.chargePointIdentifier})
</span>
)}
</p> </p>
</Modal.Body> </Modal.Body>

View File

@@ -222,6 +222,7 @@ function QrScanner({ onResult, onClose }: ScannerProps) {
function ChargePageContent() { function ChargePageContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [selectedCpId, setSelectedCpId] = useState<string | null>(null); const [selectedCpId, setSelectedCpId] = useState<string | null>(null);
@@ -234,6 +235,7 @@ function ChargePageContent() {
const [startSnapshot, setStartSnapshot] = useState<{ const [startSnapshot, setStartSnapshot] = useState<{
cpId: string; cpId: string;
chargePointIdentifier: string; chargePointIdentifier: string;
deviceName: string | null;
connectorId: number; connectorId: number;
idTag: string; idTag: string;
} | null>(null); } | null>(null);
@@ -348,6 +350,7 @@ function ChargePageContent() {
setStartSnapshot({ setStartSnapshot({
cpId: selectedCp.id, cpId: selectedCp.id,
chargePointIdentifier: selectedCp.chargePointIdentifier, chargePointIdentifier: selectedCp.chargePointIdentifier,
deviceName: selectedCp.deviceName,
connectorId: selectedConnectorId, connectorId: selectedConnectorId,
idTag: selectedIdTag, idTag: selectedIdTag,
}); });
@@ -424,7 +427,7 @@ function ChargePageContent() {
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted"></span> <span className="text-muted"></span>
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{startSnapshot?.chargePointIdentifier ?? selectedCp?.chargePointIdentifier} {startSnapshot?.deviceName ?? startSnapshot?.chargePointIdentifier ?? selectedCp?.deviceName ?? selectedCp?.chargePointIdentifier}
</span> </span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
@@ -617,12 +620,17 @@ function ChargePageContent() {
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]", : "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]",
].join(" ")} ].join(" ")}
> >
{/* Top row: identifier + status */} {/* Top row: name + status */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-semibold text-foreground truncate leading-tight"> <span className="font-semibold text-foreground truncate leading-tight">
{cp.chargePointIdentifier} {cp.deviceName ?? cp.chargePointIdentifier}
</span> </span>
{isAdmin && cp.deviceName && (
<span className="font-mono text-xs text-muted truncate">
{cp.chargePointIdentifier}
</span>
)}
{(cp.chargePointVendor || cp.chargePointModel) && ( {(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted truncate"> <span className="text-xs text-muted truncate">
{[cp.chargePointVendor, cp.chargePointModel] {[cp.chargePointVendor, cp.chargePointModel]
@@ -686,7 +694,7 @@ function ChargePageContent() {
<EvCharger className="size-3.5 text-muted" /> <EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span> <span className="text-muted"></span>
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{selectedCp.chargePointIdentifier} {selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
</span> </span>
</div> </div>
)} )}
@@ -773,7 +781,7 @@ function ChargePageContent() {
<EvCharger className="size-3.5 text-muted" /> <EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span> <span className="text-muted"></span>
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{selectedCp.chargePointIdentifier} {selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
</span> </span>
</div> </div>
)} )}

View File

@@ -285,7 +285,7 @@ function TrendChart() {
// ── RecentTransactions ──────────────────────────────────────────────────── // ── RecentTransactions ────────────────────────────────────────────────────
function RecentTransactions({ txns }: { txns: Transaction[] }) { function RecentTransactions({ txns, isAdmin = false }: { txns: Transaction[]; isAdmin?: boolean }) {
if (txns.length === 0) { if (txns.length === 0) {
return <div className="py-8 text-center text-sm text-muted"></div>; return <div className="py-8 text-center text-sm text-muted"></div>;
} }
@@ -312,10 +312,15 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{tx.chargePointIdentifier ?? "—"} {tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}
{tx.connectorNumber != null && ( {tx.connectorNumber != null && (
<span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span> <span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span>
)} )}
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
<span className="ml-1 font-mono text-xs text-muted">
({tx.chargePointIdentifier})
</span>
)}
</p> </p>
<p className="text-xs text-muted"> <p className="text-xs text-muted">
{tx.idTag} {tx.idTag}
@@ -347,7 +352,7 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
// ── ChargePointStatus ───────────────────────────────────────────────────── // ── ChargePointStatus ─────────────────────────────────────────────────────
function ChargePointStatus({ cps }: { cps: ChargePoint[] }) { function ChargePointStatus({ cps, isAdmin }: { cps: ChargePoint[]; isAdmin: boolean }) {
if (cps.length === 0) { if (cps.length === 0) {
return <div className="py-8 text-center text-sm text-muted"></div>; return <div className="py-8 text-center text-sm text-muted"></div>;
} }
@@ -369,11 +374,16 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{cp.chargePointIdentifier} {cp.deviceName ?? cp.chargePointIdentifier}
</p>
<p className="text-xs text-muted">
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
</p> </p>
{isAdmin && cp.deviceName && (
<p className="font-mono text-xs text-muted">{cp.chargePointIdentifier}</p>
)}
{!(isAdmin && cp.deviceName) && (
<p className="text-xs text-muted">
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
</p>
)}
</div> </div>
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
{online ? ( {online ? (
@@ -563,12 +573,12 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Panel title="充电桩状态"> <Panel title="充电桩状态">
<ChargePointStatus cps={data?.cps ?? []} /> <ChargePointStatus cps={data?.cps ?? []} isAdmin={isAdmin} />
</Panel> </Panel>
</div> </div>
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<Panel title="最近充电会话"> <Panel title="最近充电会话">
<RecentTransactions txns={data?.txns ?? []} /> <RecentTransactions txns={data?.txns ?? []} isAdmin={isAdmin} />
</Panel> </Panel>
</div> </div>
</div> </div>

View File

@@ -17,6 +17,7 @@ import {
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { api, type ChargePoint, type ConnectorSummary } from "@/lib/api"; import { api, type ChargePoint, type ConnectorSummary } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs"; import dayjs from "@/lib/dayjs";
import { Clock, EvCharger, Plug, Zap } from "lucide-react"; import { Clock, EvCharger, Plug, Zap } from "lucide-react";
@@ -101,10 +102,10 @@ function CsmsHubNode({ data }: NodeProps) {
// ── Charge Point Node ───────────────────────────────────────────────────── // ── Charge Point Node ─────────────────────────────────────────────────────
type ChargePointNodeData = { cp: ChargePoint; status: ConnectionStatus }; type ChargePointNodeData = { cp: ChargePoint; status: ConnectionStatus; isAdmin: boolean };
function ChargePointNode({ data }: NodeProps) { function ChargePointNode({ data }: NodeProps) {
const { cp, status } = data as ChargePointNodeData; const { cp, status, isAdmin } = data as ChargePointNodeData;
const cfg = STATUS_CONFIG[status]; const cfg = STATUS_CONFIG[status];
const hbText = cp.lastHeartbeatAt ? dayjs(cp.lastHeartbeatAt).fromNow() : "从未"; const hbText = cp.lastHeartbeatAt ? dayjs(cp.lastHeartbeatAt).fromNow() : "从未";
@@ -123,9 +124,14 @@ function ChargePointNode({ data }: NodeProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<EvCharger className="size-4 shrink-0 text-muted" /> <EvCharger className="size-4 shrink-0 text-muted" />
<span className="text-[13px] font-semibold tracking-tight text-foreground"> <div className="flex flex-col">
{cp.chargePointIdentifier} <span className="text-[13px] font-semibold tracking-tight text-foreground">
</span> {cp.deviceName ?? cp.chargePointIdentifier}
</span>
{isAdmin && cp.deviceName && (
<span className="font-mono text-[10px] text-muted">{cp.chargePointIdentifier}</span>
)}
</div>
</div> </div>
<span <span
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium" className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium"
@@ -217,6 +223,7 @@ function slotWidth(cp: ChargePoint): number {
function buildGraph( function buildGraph(
chargePoints: ChargePoint[], chargePoints: ChargePoint[],
connectedIdentifiers: string[], connectedIdentifiers: string[],
isAdmin: boolean,
): { nodes: Node[]; edges: Edge[] } { ): { nodes: Node[]; edges: Edge[] } {
// Group into rows // Group into rows
const rows: ChargePoint[][] = []; const rows: ChargePoint[][] = [];
@@ -267,7 +274,7 @@ function buildGraph(
id: cp.id, id: cp.id,
type: "chargePoint", type: "chargePoint",
position: { x: cpX, y: cpY }, position: { x: cpX, y: cpY },
data: { cp, status }, data: { cp, status, isAdmin },
draggable: true, draggable: true,
width: CP_W, width: CP_W,
height: CP_H, height: CP_H,
@@ -356,11 +363,14 @@ export default function TopologyFlow() {
refetchInterval: 10_000, refetchInterval: 10_000,
}); });
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const connectedIds = connections?.connectedIdentifiers ?? []; const connectedIds = connections?.connectedIdentifiers ?? [];
const { nodes, edges } = useMemo( const { nodes, edges } = useMemo(
() => buildGraph(chargePoints, connectedIds), () => buildGraph(chargePoints, connectedIds, isAdmin),
[chargePoints, connectedIds], [chargePoints, connectedIds, isAdmin],
); );
if (isLoading) { if (isLoading) {

View File

@@ -362,9 +362,14 @@ export default function TransactionDetailPage({ params }: { params: Promise<{ id
<dd className="font-mono text-sm text-foreground">{tx.idTag}</dd> <dd className="font-mono text-sm text-foreground">{tx.idTag}</dd>
</div> </div>
<div className="flex items-center justify-between gap-4 py-2"> <div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt> <dt className="shrink-0 text-sm text-muted"></dt>
<dd className="font-mono text-sm text-foreground"> <dd className="text-right text-sm text-foreground">
{tx.chargePointIdentifier ?? "—"} <div className="flex flex-col items-end">
<span>{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}</span>
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
<span className="font-mono text-xs text-muted">{tx.chargePointIdentifier}</span>
)}
</div>
</dd> </dd>
</div> </div>
<div className="flex items-center justify-between gap-4 py-2"> <div className="flex items-center justify-between gap-4 py-2">

View File

@@ -188,7 +188,14 @@ function TransactionsPageContent() {
{tx.id} {tx.id}
</Link> </Link>
</Table.Cell> </Table.Cell>
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</Table.Cell> <Table.Cell>
<div className="flex flex-col">
<span>{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}</span>
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
<span className="font-mono text-xs text-muted">{tx.chargePointIdentifier}</span>
)}
</div>
</Table.Cell>
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell> <Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell> <Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell> <Table.Cell>

View File

@@ -75,6 +75,7 @@ export type ConnectionsStatus = {
export type ChargePoint = { export type ChargePoint = {
id: string; id: string;
chargePointIdentifier: string; chargePointIdentifier: string;
deviceName: string | null;
chargePointVendor: string | null; chargePointVendor: string | null;
chargePointModel: string | null; chargePointModel: string | null;
registrationStatus: string; registrationStatus: string;
@@ -90,6 +91,7 @@ export type ChargePoint = {
export type ChargePointDetail = { export type ChargePointDetail = {
id: string; id: string;
chargePointIdentifier: string; chargePointIdentifier: string;
deviceName: string | null;
chargePointVendor: string | null; chargePointVendor: string | null;
chargePointModel: string | null; chargePointModel: string | null;
chargePointSerialNumber: string | null; chargePointSerialNumber: string | null;
@@ -114,6 +116,7 @@ export type ChargePointDetail = {
export type Transaction = { export type Transaction = {
id: number; id: number;
chargePointIdentifier: string | null; chargePointIdentifier: string | null;
chargePointDeviceName: string | null;
connectorNumber: number | null; connectorNumber: number | null;
idTag: string; idTag: string;
idTagStatus: string | null; idTagStatus: string | null;
@@ -216,6 +219,7 @@ export const api = {
registrationStatus?: "Accepted" | "Pending" | "Rejected"; registrationStatus?: "Accepted" | "Pending" | "Rejected";
feePerKwh?: number; feePerKwh?: number;
pricingMode?: "fixed" | "tou"; pricingMode?: "fixed" | "tou";
deviceName?: string;
}) => }) =>
apiFetch<ChargePoint>("/api/charge-points", { apiFetch<ChargePoint>("/api/charge-points", {
method: "POST", method: "POST",
@@ -229,6 +233,7 @@ export const api = {
registrationStatus?: "Accepted" | "Pending" | "Rejected"; registrationStatus?: "Accepted" | "Pending" | "Rejected";
chargePointVendor?: string; chargePointVendor?: string;
chargePointModel?: string; chargePointModel?: string;
deviceName?: string | null;
}, },
) => ) =>
apiFetch<ChargePoint>(`/api/charge-points/${id}`, { apiFetch<ChargePoint>(`/api/charge-points/${id}`, {