feat(csms): 充电桩添加 deviceName 字段,区别于 identifier 用于区分设备
This commit is contained in:
1
apps/csms/drizzle/0004_nervous_frog_thor.sql
Normal file
1
apps/csms/drizzle/0004_nervous_frog_thor.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "charge_point" ADD COLUMN "device_name" varchar(100);
|
||||||
1942
apps/csms/drizzle/meta/0004_snapshot.json
Normal file
1942
apps/csms/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
/**
|
/**
|
||||||
* 计费模式
|
* 计费模式
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<TextField fullWidth>
|
<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">
|
||||||
|
<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"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/charge-points/${cp.id}`}
|
href={`/dashboard/charge-points/${cp.id}`}
|
||||||
className="font-medium text-accent"
|
className="font-medium text-accent"
|
||||||
>
|
>
|
||||||
{cp.chargePointIdentifier}
|
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||||
</Link>
|
</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>
|
||||||
|
|||||||
@@ -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.deviceName ?? cp.chargePointIdentifier}
|
||||||
|
</span>
|
||||||
|
{isAdmin && cp.deviceName && (
|
||||||
|
<span className="font-mono text-xs text-muted truncate">
|
||||||
{cp.chargePointIdentifier}
|
{cp.chargePointIdentifier}
|
||||||
</span>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{isAdmin && cp.deviceName && (
|
||||||
|
<p className="font-mono text-xs text-muted">{cp.chargePointIdentifier}</p>
|
||||||
|
)}
|
||||||
|
{!(isAdmin && cp.deviceName) && (
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
|
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
|
||||||
</p>
|
</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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
<div className="flex flex-col">
|
||||||
<span className="text-[13px] font-semibold tracking-tight text-foreground">
|
<span className="text-[13px] font-semibold tracking-tight text-foreground">
|
||||||
{cp.chargePointIdentifier}
|
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||||
</span>
|
</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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user