chore: format code

This commit is contained in:
2026-03-11 00:18:12 +08:00
parent a3b9134299
commit 4d0c429d5f
12 changed files with 334 additions and 295 deletions

View File

@@ -17,7 +17,7 @@ import {
TextField, TextField,
} from "@heroui/react"; } from "@heroui/react";
import { ArrowLeft, Pencil, PlugConnection } from "@gravity-ui/icons"; import { ArrowLeft, Pencil, PlugConnection } from "@gravity-ui/icons";
import { api, type ChargePointDetail, type PaginatedTransactions } from "@/lib/api"; import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
// ── Status maps ──────────────────────────────────────────────────────────── // ── Status maps ────────────────────────────────────────────────────────────
@@ -116,8 +116,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
const txQuery = useQuery({ const txQuery = useQuery({
queryKey: ["chargePointTransactions", id, txPage], queryKey: ["chargePointTransactions", id, txPage],
queryFn: () => queryFn: () => api.transactions.list({ page: txPage, limit: TX_LIMIT, chargePointId: id }),
api.transactions.list({ page: txPage, limit: TX_LIMIT, chargePointId: id }),
refetchInterval: 3_000, refetchInterval: 3_000,
}); });
@@ -240,104 +239,104 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{/* Device info — admin only */} {/* Device info — admin only */}
{isAdmin && ( {isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4"> <div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2> <h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border"> <dl className="divide-y divide-border">
{[ {[
{ label: "品牌", value: cp.chargePointVendor }, { label: "品牌", value: cp.chargePointVendor },
{ label: "型号", value: cp.chargePointModel }, { label: "型号", value: cp.chargePointModel },
{ label: "序列号", value: cp.chargePointSerialNumber }, { label: "序列号", value: cp.chargePointSerialNumber },
{ label: "固件版本", value: cp.firmwareVersion }, { label: "固件版本", value: cp.firmwareVersion },
{ label: "电表型号", value: cp.meterType }, { label: "电表型号", value: cp.meterType },
{ label: "电表序列号", value: cp.meterSerialNumber }, { label: "电表序列号", value: cp.meterSerialNumber },
{ label: "ICCID", value: cp.iccid }, { label: "ICCID", value: cp.iccid },
{ label: "IMSI", value: cp.imsi }, { label: "IMSI", value: cp.imsi },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label} className="flex items-center justify-between gap-4 py-2"> <div key={label} className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted">{label}</dt> <dt className="shrink-0 text-sm text-muted">{label}</dt>
<dd className="truncate text-sm text-foreground"> <dd className="truncate text-sm text-foreground">
{value ?? <span className="text-muted"></span>} {value ?? <span className="text-muted"></span>}
</dd> </dd>
</div> </div>
))} ))}
</dl> </dl>
</div> </div>
)} )}
{/* Operation info — admin only */} {/* Operation info — admin only */}
{isAdmin && ( {isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4"> <div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2> <h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border"> <dl className="divide-y divide-border">
<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> <dd>
<Chip <Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"} color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm" size="sm"
variant="soft" variant="soft"
> >
{cp.registrationStatus} {cp.registrationStatus}
</Chip> </Chip>
</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">
<dt className="shrink-0 text-sm text-muted"></dt> <dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground"> <dd className="text-sm text-foreground">
{cp.feePerKwh > 0 ? ( {cp.feePerKwh > 0 ? (
<span> <span>
{cp.feePerKwh} /kWh {cp.feePerKwh} /kWh
<span className="ml-1 text-xs text-muted"> <span className="ml-1 text-xs text-muted">
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh) (¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
</span>
</span> </span>
</span> ) : (
) : ( "免费"
"免费" )}
)} </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"> <dt className="shrink-0 text-sm text-muted"></dt>
<dt className="shrink-0 text-sm text-muted"></dt> <dd className="text-sm text-foreground">
<dd className="text-sm text-foreground"> {cp.heartbeatInterval != null ? (
{cp.heartbeatInterval != null ? ( `${cp.heartbeatInterval}`
`${cp.heartbeatInterval}` ) : (
) : ( <span className="text-muted"></span>
<span className="text-muted"></span> )}
)} </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"> <dt className="shrink-0 text-sm text-muted"></dt>
<dt className="shrink-0 text-sm text-muted"></dt> <dd className="text-right text-sm text-foreground">
<dd className="text-right text-sm text-foreground"> {cp.lastHeartbeatAt ? (
{cp.lastHeartbeatAt ? ( <span title={new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")}>
<span title={new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")}> {relativeTime(cp.lastHeartbeatAt)}
{relativeTime(cp.lastHeartbeatAt)} </span>
</span> ) : (
) : ( <span className="text-muted"></span>
<span className="text-muted"></span> )}
)} </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"> <dt className="shrink-0 text-sm text-muted"></dt>
<dt className="shrink-0 text-sm text-muted"></dt> <dd className="text-right text-sm text-foreground">
<dd className="text-right text-sm text-foreground"> {cp.lastBootNotificationAt ? (
{cp.lastBootNotificationAt ? ( <span title={new Date(cp.lastBootNotificationAt).toLocaleString("zh-CN")}>
<span title={new Date(cp.lastBootNotificationAt).toLocaleString("zh-CN")}> {relativeTime(cp.lastBootNotificationAt)}
{relativeTime(cp.lastBootNotificationAt)} </span>
</span> ) : (
) : ( <span className="text-muted"></span>
<span className="text-muted"></span> )}
)} </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"> <dt className="shrink-0 text-sm text-muted"></dt>
<dt className="shrink-0 text-sm text-muted"></dt> <dd className="text-sm text-foreground">
<dd className="text-sm text-foreground"> {new Date(cp.createdAt).toLocaleDateString("zh-CN")}
{new Date(cp.createdAt).toLocaleDateString("zh-CN")} </dd>
</dd> </div>
</div> </dl>
</dl> </div>
</div>
)} )}
{/* Fee info — user only */} {/* Fee info — user only */}
@@ -362,7 +361,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dt className="shrink-0 text-sm text-muted"></dt> <dt className="shrink-0 text-sm text-muted"></dt>
<dd> <dd>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`} /> <span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
/>
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span> <span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
</div> </div>
</dd> </dd>

View File

@@ -337,7 +337,12 @@ export default function IdTagsPage() {
const [deletingTag, setDeletingTag] = useState<string | null>(null); const [deletingTag, setDeletingTag] = useState<string | null>(null);
const [claiming, setClaiming] = useState(false); const [claiming, setClaiming] = useState(false);
const { data: idTagsData, isPending: loading, isFetching: refreshing, refetch } = useQuery({ const {
data: idTagsData,
isPending: loading,
isFetching: refreshing,
refetch,
} = useQuery({
queryKey: ["idTags"], queryKey: ["idTags"],
queryFn: async () => { queryFn: async () => {
const [tagList, userList] = await Promise.all([ const [tagList, userList] = await Promise.all([
@@ -435,38 +440,38 @@ export default function IdTagsPage() {
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} /> <ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button> </Button>
<Modal> <Modal>
<Button size="sm" variant="secondary" onPress={openCreate}> <Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" /> <Plus className="size-4" />
</Button> </Button>
<Modal.Backdrop> <Modal.Backdrop>
<Modal.Container scroll="outside"> <Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105"> <Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger /> <Modal.CloseTrigger />
<Modal.Header> <Modal.Header>
<Modal.Heading></Modal.Heading> <Modal.Heading></Modal.Heading>
</Modal.Header> </Modal.Header>
<Modal.Body className="space-y-3"> <Modal.Body className="space-y-3">
<TagFormBody <TagFormBody
form={form} form={form}
setForm={setForm} setForm={setForm}
isEdit={false} isEdit={false}
users={users} users={users}
tags={tags} tags={tags}
/> />
</Modal.Body> </Modal.Body>
<Modal.Footer className="flex justify-end gap-2"> <Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost"> <Button slot="close" variant="ghost">
</Button> </Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}> <Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "创建"} {saving ? <Spinner size="sm" /> : "创建"}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal.Dialog> </Modal.Dialog>
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
</div> </div>
) : ( ) : (
<Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}> <Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}>

View File

@@ -1,6 +1,6 @@
import { ReactNode } from 'react' import { ReactNode } from "react";
import Sidebar from '@/components/sidebar' import Sidebar from "@/components/sidebar";
import { ReactQueryProvider } from '@/components/query-provider' import { ReactQueryProvider } from "@/components/query-provider";
export default function DashboardLayout({ children }: { children: ReactNode }) { export default function DashboardLayout({ children }: { children: ReactNode }) {
return ( return (
@@ -11,12 +11,10 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<main className="flex-1 overflow-y-auto pt-14 lg:pt-0"> <main className="flex-1 overflow-y-auto pt-14 lg:pt-0">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<ReactQueryProvider> <ReactQueryProvider>{children}</ReactQueryProvider>
{children}
</ReactQueryProvider>
</div> </div>
</main> </main>
</div> </div>
</div> </div>
) );
} }

View File

@@ -118,9 +118,7 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
<span <span
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${active ? "bg-warning-soft" : "bg-success/10"}`} className={`flex size-7 shrink-0 items-center justify-center rounded-full ${active ? "bg-warning-soft" : "bg-success/10"}`}
> >
<Thunderbolt <Thunderbolt className={`size-3.5 ${active ? "text-warning" : "text-success"}`} />
className={`size-3.5 ${active ? "text-warning" : "text-success"}`}
/>
</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">
@@ -174,9 +172,7 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
{chargingCount > 0 && ( {chargingCount > 0 && (
<p className="text-xs font-medium text-warning">{chargingCount} </p> <p className="text-xs font-medium text-warning">{chargingCount} </p>
)} )}
<p className="text-xs text-muted"> <p className="text-xs text-muted">{online ? `${availableCount} 可用` : "离线"}</p>
{online ? `${availableCount} 可用` : "离线"}
</p>
</div> </div>
</li> </li>
); );
@@ -283,9 +279,7 @@ export default function DashboardPage() {
footer={ footer={
<> <>
<StatusDot color="success" /> <StatusDot color="success" />
<span className="font-medium text-success"> <span className="font-medium text-success">{s?.onlineChargePoints ?? 0} 线</span>
{s?.onlineChargePoints ?? 0} 线
</span>
<span className="text-border">·</span> <span className="text-border">·</span>
<span>{offlineCount} 线</span> <span>{offlineCount} 线</span>
</> </>
@@ -383,4 +377,3 @@ export default function DashboardPage() {
</div> </div>
); );
} }

View File

@@ -223,27 +223,35 @@ export default function SettingsPage() {
placeholder="对外显示的名称" placeholder="对外显示的名称"
value={profileName} value={profileName}
onChange={(e) => setProfileName(e.target.value)} onChange={(e) => setProfileName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleSaveProfile(); }} onKeyDown={(e) => {
if (e.key === "Enter") void handleSaveProfile();
}}
/> />
</TextField> </TextField>
{profileError && ( {profileError && (
<Alert status="danger"> <Alert status="danger">
<Alert.Indicator /> <Alert.Indicator />
<Alert.Content><Alert.Description>{profileError}</Alert.Description></Alert.Content> <Alert.Content>
<Alert.Description>{profileError}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setProfileError("")} /> <CloseButton onPress={() => setProfileError("")} />
</Alert> </Alert>
)} )}
{profileSuccess && ( {profileSuccess && (
<Alert status="success"> <Alert status="success">
<Alert.Indicator /> <Alert.Indicator />
<Alert.Content><Alert.Description>{profileSuccess}</Alert.Description></Alert.Content> <Alert.Content>
<Alert.Description>{profileSuccess}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setProfileSuccess("")} /> <CloseButton onPress={() => setProfileSuccess("")} />
</Alert> </Alert>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
size="sm" size="sm"
isDisabled={savingProfile || !profileName.trim() || profileName === session?.user.name} isDisabled={
savingProfile || !profileName.trim() || profileName === session?.user.name
}
onPress={handleSaveProfile} onPress={handleSaveProfile}
> >
{savingProfile ? <Spinner size="sm" color="current" /> : "保存"} {savingProfile ? <Spinner size="sm" color="current" /> : "保存"}
@@ -292,20 +300,26 @@ export default function SettingsPage() {
placeholder="再次输入新密码" placeholder="再次输入新密码"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }} onKeyDown={(e) => {
if (e.key === "Enter") void handleChangePassword();
}}
/> />
</TextField> </TextField>
{pwError && ( {pwError && (
<Alert status="danger"> <Alert status="danger">
<Alert.Indicator /> <Alert.Indicator />
<Alert.Content><Alert.Description>{pwError}</Alert.Description></Alert.Content> <Alert.Content>
<Alert.Description>{pwError}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setPwError("")} /> <CloseButton onPress={() => setPwError("")} />
</Alert> </Alert>
)} )}
{pwSuccess && ( {pwSuccess && (
<Alert status="success"> <Alert status="success">
<Alert.Indicator /> <Alert.Indicator />
<Alert.Content><Alert.Description>{pwSuccess}</Alert.Description></Alert.Content> <Alert.Content>
<Alert.Description>{pwSuccess}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setPwSuccess("")} /> <CloseButton onPress={() => setPwSuccess("")} />
</Alert> </Alert>
)} )}
@@ -369,7 +383,9 @@ export default function SettingsPage() {
<div className="border-b border-border px-5 py-3"> <div className="border-b border-border px-5 py-3">
<Alert status="danger"> <Alert status="danger">
<Alert.Indicator /> <Alert.Indicator />
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content> <Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setError("")} /> <CloseButton onPress={() => setError("")} />
</Alert> </Alert>
</div> </div>
@@ -378,7 +394,9 @@ export default function SettingsPage() {
<div className="border-b border-border px-5 py-3"> <div className="border-b border-border px-5 py-3">
<Alert status="success"> <Alert status="success">
<Alert.Indicator /> <Alert.Indicator />
<Alert.Content><Alert.Description>{success}</Alert.Description></Alert.Content> <Alert.Content>
<Alert.Description>{success}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setSuccess("")} /> <CloseButton onPress={() => setSuccess("")} />
</Alert> </Alert>
</div> </div>

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react"; import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import { TrashBin } from "@gravity-ui/icons"; import { TrashBin } from "@gravity-ui/icons";
import { api, type PaginatedTransactions } from "@/lib/api"; import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
const LIMIT = 15; const LIMIT = 15;
@@ -27,7 +27,11 @@ export default function TransactionsPage() {
const [stoppingId, setStoppingId] = useState<number | null>(null); const [stoppingId, setStoppingId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null); const [deletingId, setDeletingId] = useState<number | null>(null);
const { data, isPending: loading, refetch } = useQuery({ const {
data,
isPending: loading,
refetch,
} = useQuery({
queryKey: ["transactions", page, status], queryKey: ["transactions", page, status],
queryFn: () => queryFn: () =>
api.transactions.list({ api.transactions.list({
@@ -197,53 +201,54 @@ export default function TransactionsPage() {
</Modal> </Modal>
)} )}
{isAdmin && ( {isAdmin && (
<Modal> <Modal>
<Button <Button
isIconOnly isIconOnly
size="sm" size="sm"
variant="tertiary" variant="tertiary"
isDisabled={deletingId === tx.id} isDisabled={deletingId === tx.id}
> >
{deletingId === tx.id ? ( {deletingId === tx.id ? (
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<TrashBin className="size-4" /> <TrashBin className="size-4" />
)} )}
</Button> </Button>
<Modal.Backdrop> <Modal.Backdrop>
<Modal.Container scroll="outside"> <Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96"> <Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger /> <Modal.CloseTrigger />
<Modal.Header> <Modal.Header>
<Modal.Heading></Modal.Heading> <Modal.Heading></Modal.Heading>
</Modal.Header> </Modal.Header>
<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-mono font-medium text-foreground">
#{tx.id} #{tx.id}
</span> </span>
<span className="font-mono">{tx.idTag}</span> <span className="font-mono">{tx.idTag}</span>
{!tx.stopTimestamp && "该记录仍进行中,删除同时将重置接口状态。"} {!tx.stopTimestamp &&
</p> "该记录仍进行中,删除同时将重置接口状态。"}
</Modal.Body> </p>
<Modal.Footer className="flex justify-end gap-2"> </Modal.Body>
<Button slot="close" variant="ghost"> <Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button </Button>
slot="close" <Button
variant="danger" slot="close"
isDisabled={deletingId === tx.id} variant="danger"
onPress={() => handleDelete(tx.id)} isDisabled={deletingId === tx.id}
> onPress={() => handleDelete(tx.id)}
{deletingId === tx.id ? <Spinner size="sm" /> : "确认删除"} >
</Button> {deletingId === tx.id ? <Spinner size="sm" /> : "确认删除"}
</Modal.Footer> </Button>
</Modal.Dialog> </Modal.Footer>
</Modal.Container> </Modal.Dialog>
</Modal.Backdrop> </Modal.Container>
</Modal> </Modal.Backdrop>
</Modal>
)} )}
</div> </div>
</Table.Cell> </Table.Cell>

View File

@@ -5,12 +5,12 @@ import "./globals.css";
const fontSaira = Saira({ const fontSaira = Saira({
variable: "--font-saira", variable: "--font-saira",
subsets: ["latin"], subsets: ["latin"],
}) });
const fontNotoSans = Noto_Sans({ const fontNotoSans = Noto_Sans({
variable: "--font-noto-sans", variable: "--font-noto-sans",
subsets: ["latin"], subsets: ["latin"],
}) });
const fontMono = Geist_Mono({ const fontMono = Geist_Mono({
variable: "--font-geist-mono", variable: "--font-geist-mono",
@@ -19,7 +19,8 @@ const fontMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Helios EVCS", title: "Helios EVCS",
description: "A modern EV charging station management system built with Next.js, Drizzle ORM, and OCPP.", description:
"A modern EV charging station management system built with Next.js, Drizzle ORM, and OCPP.",
}; };
export default function RootLayout({ export default function RootLayout({

View File

@@ -141,7 +141,7 @@ export default function LoginPage() {
</Card.Content> </Card.Content>
</Card> </Card>
<p className="mt-6 text-xs text-muted/60">OCPP 1.6-J Protocol v0.1.0</p> <p className="mt-6 text-xs text-muted/60">Helios EVCS</p>
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function Home() { export default function Home() {
redirect('/dashboard') redirect("/dashboard");
} }

View File

@@ -1,18 +1,18 @@
'use client' "use client";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { ArrowRightFromSquare, PersonFill } from '@gravity-ui/icons' import { ArrowRightFromSquare, PersonFill } from "@gravity-ui/icons";
import { signOut, useSession } from '@/lib/auth-client' import { signOut, useSession } from "@/lib/auth-client";
export default function SidebarFooter() { export default function SidebarFooter() {
const router = useRouter() const router = useRouter();
const { data: session } = useSession() const { data: session } = useSession();
const handleSignOut = async () => { const handleSignOut = async () => {
await signOut({ fetchOptions: { credentials: 'include' } }) await signOut({ fetchOptions: { credentials: "include" } });
router.push('/login') router.push("/login");
router.refresh() router.refresh();
} };
return ( return (
<div className="border-t border-border p-3"> <div className="border-t border-border p-3">
@@ -27,7 +27,7 @@ export default function SidebarFooter() {
{session.user.name || session.user.email} {session.user.name || session.user.email}
</p> </p>
<p className="truncate text-xs leading-tight text-muted capitalize"> <p className="truncate text-xs leading-tight text-muted capitalize">
{session.user.role ?? 'user'} {session.user.role ?? "user"}
</p> </p>
</div> </div>
</div> </div>
@@ -44,5 +44,5 @@ export default function SidebarFooter() {
<p className="mt-2 px-2 text-[11px] text-muted/60">Helios EVCS</p> <p className="mt-2 px-2 text-[11px] text-muted/60">Helios EVCS</p>
</div> </div>
) );
} }

View File

@@ -1,25 +1,40 @@
'use client' "use client";
import Link from 'next/link' import Link from "next/link";
import { usePathname } from 'next/navigation' import { usePathname } from "next/navigation";
import { useState } from 'react' import { useState } from "react";
import { CreditCard, Gear, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons' import {
import SidebarFooter from '@/components/sidebar-footer' CreditCard,
import { useSession } from '@/lib/auth-client' Gear,
ListCheck,
Person,
PlugConnection,
Thunderbolt,
Xmark,
Bars,
} from "@gravity-ui/icons";
import SidebarFooter from "@/components/sidebar-footer";
import { useSession } from "@/lib/auth-client";
const navItems = [ const navItems = [
{ href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true, adminOnly: false }, { href: "/dashboard", label: "概览", icon: Thunderbolt, exact: true, adminOnly: false },
{ href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection, adminOnly: false }, { href: "/dashboard/charge-points", label: "充电桩", icon: PlugConnection, adminOnly: false },
{ href: '/dashboard/transactions', label: '充电记录', icon: ListCheck, adminOnly: false }, { href: "/dashboard/transactions", label: "充电记录", icon: ListCheck, adminOnly: false },
{ href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard, adminOnly: false }, { href: "/dashboard/id-tags", label: "储值卡", icon: CreditCard, adminOnly: false },
{ href: '/dashboard/users', label: '用户管理', icon: Person, adminOnly: true }, { href: "/dashboard/users", label: "用户管理", icon: Person, adminOnly: true },
] ];
const settingsItems = [ const settingsItems = [{ href: "/dashboard/settings", label: "账号设置", icon: Gear }];
{ href: '/dashboard/settings', label: '账号设置', icon: Gear },
]
function NavContent({ pathname, isAdmin, onNavigate }: { pathname: string; isAdmin: boolean; onNavigate?: () => void }) { function NavContent({
pathname,
isAdmin,
onNavigate,
}: {
pathname: string;
isAdmin: boolean;
onNavigate?: () => void;
}) {
return ( return (
<> <>
{/* Logo */} {/* Logo */}
@@ -37,70 +52,68 @@ function NavContent({ pathname, isAdmin, onNavigate }: { pathname: string; isAdm
<p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted"> <p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p> </p>
{navItems.filter((item) => !item.adminOnly || isAdmin).map((item) => { {navItems
const isActive = item.exact .filter((item) => !item.adminOnly || isAdmin)
? pathname === item.href .map((item) => {
: pathname === item.href || pathname.startsWith(item.href + '/') const isActive = item.exact
const Icon = item.icon ? pathname === item.href
return ( : pathname === item.href || pathname.startsWith(item.href + "/");
<Link const Icon = item.icon;
key={item.href} return (
href={item.href} <Link
onClick={onNavigate} key={item.href}
className={[ href={item.href}
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors', onClick={onNavigate}
isActive className={[
? 'bg-accent/10 text-accent' "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
: 'text-muted hover:bg-surface-tertiary hover:text-foreground', isActive
].join(' ')} ? "bg-accent/10 text-accent"
> : "text-muted hover:bg-surface-tertiary hover:text-foreground",
<Icon className="size-4 shrink-0" /> ].join(" ")}
<span>{item.label}</span> >
{isActive && ( <Icon className="size-4 shrink-0" />
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" /> <span>{item.label}</span>
)} {isActive && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />}
</Link> </Link>
) );
})} })}
<p className="mb-1 mt-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted"> <p className="mb-1 mt-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p> </p>
{settingsItems.map((item) => { {settingsItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/') const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon const Icon = item.icon;
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
onClick={onNavigate} onClick={onNavigate}
className={[ className={[
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors', "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive isActive
? 'bg-accent/10 text-accent' ? "bg-accent/10 text-accent"
: 'text-muted hover:bg-surface-tertiary hover:text-foreground', : "text-muted hover:bg-surface-tertiary hover:text-foreground",
].join(' ')} ].join(" ")}
> >
<Icon className="size-4 shrink-0" /> <Icon className="size-4 shrink-0" />
<span>{item.label}</span> <span>{item.label}</span>
{isActive && ( {isActive && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />}
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />
)}
</Link> </Link>
) );
})} })}
</nav> </nav>
{/* Footer */} {/* Footer */}
<SidebarFooter /> <SidebarFooter />
</> </>
) );
} }
export default function Sidebar() { export default function Sidebar() {
const pathname = usePathname() const pathname = usePathname();
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
const { data: sessionData } = useSession() const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin" const isAdmin = sessionData?.user?.role === "admin";
return ( return (
<> <>
@@ -124,18 +137,15 @@ export default function Sidebar() {
{/* Mobile drawer overlay */} {/* Mobile drawer overlay */}
{open && ( {open && (
<div <div className="fixed inset-0 z-40 bg-black/50 lg:hidden" onClick={() => setOpen(false)} />
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)} )}
{/* Mobile drawer */} {/* Mobile drawer */}
<aside <aside
className={[ className={[
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-border bg-surface-secondary transition-transform duration-300 lg:hidden', "fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-border bg-surface-secondary transition-transform duration-300 lg:hidden",
open ? 'translate-x-0' : '-translate-x-full', open ? "translate-x-0" : "-translate-x-full",
].join(' ')} ].join(" ")}
> >
<button <button
type="button" type="button"
@@ -153,5 +163,5 @@ export default function Sidebar() {
<NavContent pathname={pathname} isAdmin={isAdmin} /> <NavContent pathname={pathname} isAdmin={isAdmin} />
</aside> </aside>
</> </>
) );
} }

View File

@@ -156,12 +156,15 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
update: (id: string, data: { update: (
feePerKwh?: number; id: string,
registrationStatus?: "Accepted" | "Pending" | "Rejected"; data: {
chargePointVendor?: string; feePerKwh?: number;
chargePointModel?: string; registrationStatus?: "Accepted" | "Pending" | "Rejected";
}) => chargePointVendor?: string;
chargePointModel?: string;
},
) =>
apiFetch<ChargePoint>(`/api/charge-points/${id}`, { apiFetch<ChargePoint>(`/api/charge-points/${id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -170,7 +173,12 @@ export const api = {
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }), apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
}, },
transactions: { transactions: {
list: (params?: { page?: number; limit?: number; status?: "active" | "completed"; chargePointId?: string }) => { list: (params?: {
page?: number;
limit?: number;
status?: "active" | "completed";
chargePointId?: string;
}) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params?.page) q.set("page", String(params.page)); if (params?.page) q.set("page", String(params.page));
if (params?.limit) q.set("limit", String(params.limit)); if (params?.limit) q.set("limit", String(params.limit));