feat: integrate React Query for data fetching and state management across dashboard components

This commit is contained in:
2026-03-11 00:15:07 +08:00
parent 984274bfb7
commit eac81d2fab
9 changed files with 121 additions and 154 deletions

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { use, useCallback, useEffect, useState } from "react"; import { use, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link"; import Link from "next/link";
import { import {
Button, Button,
@@ -93,14 +94,8 @@ type EditForm = {
export default function ChargePointDetailPage({ params }: { params: Promise<{ id: string }> }) { export default function ChargePointDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params); const { id } = use(params);
const [cp, setCp] = useState<ChargePointDetail | null>(null);
const [notFound, setNotFound] = useState(false);
const [loading, setLoading] = useState(true);
// transactions // transactions
const [txData, setTxData] = useState<PaginatedTransactions | null>(null);
const [txPage, setTxPage] = useState(1); const [txPage, setTxPage] = useState(1);
const [txLoading, setTxLoading] = useState(true);
// edit modal // edit modal
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -112,42 +107,22 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
feePerKwh: "0", feePerKwh: "0",
}); });
const loadCp = useCallback(async () => { const cpQuery = useQuery({
setLoading(true); queryKey: ["chargePoint", id],
try { queryFn: () => api.chargePoints.get(id),
const data = await api.chargePoints.get(id); refetchInterval: 3_000,
setCp(data); retry: false,
} catch {
setNotFound(true);
} finally {
setLoading(false);
}
}, [id]);
const loadTx = useCallback(
async (p: number) => {
setTxLoading(true);
try {
const data = await api.transactions.list({
page: p,
limit: TX_LIMIT,
chargePointId: id,
}); });
setTxData(data);
} finally {
setTxLoading(false);
}
},
[id],
);
useEffect(() => { const txQuery = useQuery({
loadCp(); queryKey: ["chargePointTransactions", id, txPage],
}, [loadCp]); queryFn: () =>
api.transactions.list({ page: txPage, limit: TX_LIMIT, chargePointId: id }),
refetchInterval: 3_000,
});
useEffect(() => { const cp = cpQuery.data;
loadTx(txPage); const txData = txQuery.data;
}, [txPage, loadTx]);
const openEdit = () => { const openEdit = () => {
if (!cp) return; if (!cp) return;
@@ -165,13 +140,13 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
setEditBusy(true); setEditBusy(true);
try { try {
const fee = Math.max(0, Math.round(Number(editForm.feePerKwh) || 0)); const fee = Math.max(0, Math.round(Number(editForm.feePerKwh) || 0));
const updated = await api.chargePoints.update(cp.id, { await api.chargePoints.update(cp.id, {
chargePointVendor: editForm.chargePointVendor, chargePointVendor: editForm.chargePointVendor,
chargePointModel: editForm.chargePointModel, chargePointModel: editForm.chargePointModel,
registrationStatus: editForm.registrationStatus, registrationStatus: editForm.registrationStatus,
feePerKwh: fee, feePerKwh: fee,
}); });
setCp((prev) => (prev ? { ...prev, ...updated, connectors: prev.connectors } : prev)); await cpQuery.refetch();
setEditOpen(false); setEditOpen(false);
} finally { } finally {
setEditBusy(false); setEditBusy(false);
@@ -188,7 +163,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
// ── Render: loading / not found ────────────────────────────────────────── // ── Render: loading / not found ──────────────────────────────────────────
if (loading) { if (cpQuery.isPending) {
return ( return (
<div className="flex h-48 items-center justify-center"> <div className="flex h-48 items-center justify-center">
<Spinner /> <Spinner />
@@ -196,7 +171,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
); );
} }
if (notFound || !cp) { if (!cp) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Link <Link
@@ -463,7 +438,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Table.Body <Table.Body
renderEmptyState={() => ( renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted"> <div className="py-8 text-center text-sm text-muted">
{txLoading ? "加载中…" : "暂无充电记录"} {txQuery.isPending ? "加载中…" : "暂无充电记录"}
</div> </div>
)} )}
> >

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { import {
Button, Button,
Chip, Chip,
@@ -67,26 +68,17 @@ const EMPTY_FORM: FormData = {
}; };
export default function ChargePointsPage() { export default function ChargePointsPage() {
const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [formTarget, setFormTarget] = useState<ChargePoint | null>(null); const [formTarget, setFormTarget] = useState<ChargePoint | null>(null);
const [formData, setFormData] = useState<FormData>(EMPTY_FORM); const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
const [formBusy, setFormBusy] = useState(false); const [formBusy, setFormBusy] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null); const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const hasFetched = useRef(false); const { data: chargePoints = [], refetch: refetchList } = useQuery({
queryKey: ["chargePoints"],
const load = useCallback(async () => { queryFn: () => api.chargePoints.list().catch(() => []),
const data = await api.chargePoints.list().catch(() => []); refetchInterval: 3_000,
setChargePoints(data); });
}, []);
useEffect(() => {
if (!hasFetched.current) {
hasFetched.current = true;
load();
}
}, [load]);
const openCreate = () => { const openCreate = () => {
setFormTarget(null); setFormTarget(null);
@@ -113,28 +105,23 @@ export default function ChargePointsPage() {
const fee = Math.max(0, Math.round(Number(formData.feePerKwh) || 0)); const fee = Math.max(0, Math.round(Number(formData.feePerKwh) || 0));
if (formTarget) { if (formTarget) {
// Edit // Edit
const updated = await api.chargePoints.update(String(formTarget.id), { await api.chargePoints.update(String(formTarget.id), {
chargePointVendor: formData.chargePointVendor, chargePointVendor: formData.chargePointVendor,
chargePointModel: formData.chargePointModel, chargePointModel: formData.chargePointModel,
registrationStatus: formData.registrationStatus, registrationStatus: formData.registrationStatus,
feePerKwh: fee, feePerKwh: fee,
}); });
setChargePoints((prev) =>
prev.map((cp) =>
cp.id === formTarget.id ? { ...updated, connectors: cp.connectors } : cp,
),
);
} else { } else {
// Create // Create
const created = await api.chargePoints.create({ await api.chargePoints.create({
chargePointIdentifier: formData.chargePointIdentifier.trim(), chargePointIdentifier: formData.chargePointIdentifier.trim(),
chargePointVendor: formData.chargePointVendor.trim() || undefined, chargePointVendor: formData.chargePointVendor.trim() || undefined,
chargePointModel: formData.chargePointModel.trim() || undefined, chargePointModel: formData.chargePointModel.trim() || undefined,
registrationStatus: formData.registrationStatus, registrationStatus: formData.registrationStatus,
feePerKwh: fee, feePerKwh: fee,
}); });
setChargePoints((prev) => [created, ...prev]);
} }
await refetchList();
setFormOpen(false); setFormOpen(false);
} finally { } finally {
setFormBusy(false); setFormBusy(false);
@@ -146,7 +133,7 @@ export default function ChargePointsPage() {
setDeleting(true); setDeleting(true);
try { try {
await api.chargePoints.delete(String(deleteTarget.id)); await api.chargePoints.delete(String(deleteTarget.id));
setChargePoints((prev) => prev.filter((cp) => cp.id !== deleteTarget.id)); await refetchList();
setDeleteTarget(null); setDeleteTarget(null);
} finally { } finally {
setDeleting(false); setDeleting(false);

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { import {
Autocomplete, Autocomplete,
Button, Button,
@@ -22,7 +23,7 @@ import {
useFilter, useFilter,
} from "@heroui/react"; } from "@heroui/react";
import { parseDate } from "@internationalized/date"; import { parseDate } from "@internationalized/date";
import { Pencil, Plus, TrashBin } from "@gravity-ui/icons"; import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api"; import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
@@ -330,43 +331,36 @@ function TagFormBody({
export default function IdTagsPage() { export default function IdTagsPage() {
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin"; const isAdmin = sessionData?.user?.role === "admin";
const [tags, setTags] = useState<IdTag[]>([]);
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<IdTag | null>(null); const [editing, setEditing] = useState<IdTag | null>(null);
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
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 handleClaim = async () => { const { data: idTagsData, isPending: loading, isFetching: refreshing, refetch } = useQuery({
setClaiming(true); queryKey: ["idTags"],
try { queryFn: async () => {
await api.idTags.claim();
await load();
} finally {
setClaiming(false);
}
};
const load = async () => {
setLoading(true);
try {
const [tagList, userList] = await Promise.all([ const [tagList, userList] = await Promise.all([
api.idTags.list(), api.idTags.list(),
api.users.list().catch(() => [] as UserRow[]), api.users.list().catch(() => [] as UserRow[]),
]); ]);
setTags(tagList); return { tags: tagList, users: userList };
setUsers(userList); },
});
const tags = idTagsData?.tags ?? [];
const users = idTagsData?.users ?? [];
const handleClaim = async () => {
setClaiming(true);
try {
await api.idTags.claim();
await refetch();
} finally { } finally {
setLoading(false); setClaiming(false);
} }
}; };
useEffect(() => {
load();
}, []);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
setForm(emptyForm); setForm(emptyForm);
@@ -405,7 +399,7 @@ export default function IdTagsPage() {
balance: yuanToFen(form.balance), balance: yuanToFen(form.balance),
}); });
} }
await load(); await refetch();
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -415,7 +409,7 @@ export default function IdTagsPage() {
setDeletingTag(idTag); setDeletingTag(idTag);
try { try {
await api.idTags.delete(idTag); await api.idTags.delete(idTag);
await load(); await refetch();
} finally { } finally {
setDeletingTag(null); setDeletingTag(null);
} }
@@ -429,6 +423,17 @@ export default function IdTagsPage() {
<p className="mt-0.5 text-sm text-muted"> {tags.length} </p> <p className="mt-0.5 text-sm text-muted"> {tags.length} </p>
</div> </div>
{isAdmin ? ( {isAdmin ? (
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</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" />
@@ -462,6 +467,7 @@ export default function IdTagsPage() {
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
</div>
) : ( ) : (
<Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}> <Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}>
<Plus className="size-4" /> <Plus className="size-4" />

View File

@@ -1,5 +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'
export default function DashboardLayout({ children }: { children: ReactNode }) { export default function DashboardLayout({ children }: { children: ReactNode }) {
return ( return (
@@ -10,7 +11,9 @@ 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>
{children} {children}
</ReactQueryProvider>
</div> </div>
</main> </main>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query";
import { Card, Spinner } from "@heroui/react"; import { Card, Spinner } from "@heroui/react";
import { import {
Thunderbolt, Thunderbolt,
@@ -191,37 +191,21 @@ export default function DashboardPage() {
const { data: sessionData, isPending } = useSession(); const { data: sessionData, isPending } = useSession();
const isAdmin = sessionData?.user?.role === "admin"; const isAdmin = sessionData?.user?.role === "admin";
const [adminStats, setAdminStats] = useState<Stats | null>(null); const { data, isPending: queryPending } = useQuery({
const [userStats, setUserStats] = useState<UserStats | null>(null); queryKey: ["dashboard", isAdmin],
const [recentTxns, setRecentTxns] = useState<Transaction[]>([]); queryFn: async () => {
const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]); const [statsRes, txRes, cpsData] = await Promise.all([
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (isPending) return;
setLoading(true);
try {
const [statsData, txnsData, cpsData] = await Promise.all([
api.stats.get(), api.stats.get(),
api.transactions.list({ limit: 6 }), api.transactions.list({ limit: 6 }),
isAdmin ? api.chargePoints.list() : Promise.resolve(null), isAdmin ? api.chargePoints.list() : Promise.resolve([] as ChargePoint[]),
]); ]);
if ("totalChargePoints" in statsData) { return { stats: statsRes, txns: txRes.data, cps: cpsData };
setAdminStats(statsData as Stats); },
} else { refetchInterval: 3_000,
setUserStats(statsData as UserStats); enabled: !isPending,
} });
setRecentTxns(txnsData.data);
if (cpsData) setChargePoints(cpsData);
} catch {}
finally {
setLoading(false);
}
}, [isPending, isAdmin]);
useEffect(() => { void load(); }, [load]); if (isPending || queryPending) {
if (isPending || loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -238,7 +222,7 @@ export default function DashboardPage() {
// ── Admin view ──────────────────────────────────────────────────────────── // ── Admin view ────────────────────────────────────────────────────────────
if (isAdmin) { if (isAdmin) {
const s = adminStats; const s = data?.stats as Stats | undefined;
const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(1) : "—"; const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(1) : "—";
const todayRevenue = s ? `¥${(s.todayRevenue / 100).toFixed(2)}` : "—"; const todayRevenue = s ? `¥${(s.todayRevenue / 100).toFixed(2)}` : "—";
const offlineCount = (s?.totalChargePoints ?? 0) - (s?.onlineChargePoints ?? 0); const offlineCount = (s?.totalChargePoints ?? 0) - (s?.onlineChargePoints ?? 0);
@@ -327,12 +311,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={chargePoints} /> <ChargePointStatus cps={data?.cps ?? []} />
</Panel> </Panel>
</div> </div>
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<Panel title="最近充电会话"> <Panel title="最近充电会话">
<RecentTransactions txns={recentTxns} /> <RecentTransactions txns={data?.txns ?? []} />
</Panel> </Panel>
</div> </div>
</div> </div>
@@ -342,7 +326,7 @@ export default function DashboardPage() {
// ── User view ───────────────────────────────────────────────────────────── // ── User view ─────────────────────────────────────────────────────────────
const s = userStats; const s = data?.stats as UserStats | undefined;
const totalYuan = s ? (s.totalBalance / 100).toFixed(2) : "—"; const totalYuan = s ? (s.totalBalance / 100).toFixed(2) : "—";
const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(2) : "—"; const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(2) : "—";
@@ -394,7 +378,7 @@ export default function DashboardPage() {
</div> </div>
<Panel title="最近充电记录"> <Panel title="最近充电记录">
<RecentTransactions txns={recentTxns} /> <RecentTransactions txns={data?.txns ?? []} />
</Panel> </Panel>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
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, type PaginatedTransactions } from "@/lib/api";
@@ -21,30 +22,21 @@ function formatDuration(start: string, stop: string | null): string {
export default function TransactionsPage() { export default function TransactionsPage() {
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin"; const isAdmin = sessionData?.user?.role === "admin";
const [data, setData] = useState<PaginatedTransactions | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">("all"); const [status, setStatus] = useState<"all" | "active" | "completed">("all");
const [loading, setLoading] = useState(true);
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 load = useCallback(async (p: number, s: typeof status) => { const { data, isPending: loading, refetch } = useQuery({
setLoading(true); queryKey: ["transactions", page, status],
try { queryFn: () =>
const res = await api.transactions.list({ api.transactions.list({
page: p, page,
limit: LIMIT, limit: LIMIT,
status: s === "all" ? undefined : s, status: status === "all" ? undefined : status,
}),
refetchInterval: 3_000,
}); });
setData(res);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(page, status);
}, [page, status, load]);
const handleStatusChange = (s: typeof status) => { const handleStatusChange = (s: typeof status) => {
setStatus(s); setStatus(s);
@@ -55,7 +47,7 @@ export default function TransactionsPage() {
setStoppingId(id); setStoppingId(id);
try { try {
await api.transactions.stop(id); await api.transactions.stop(id);
await load(page, status); await refetch();
} finally { } finally {
setStoppingId(null); setStoppingId(null);
} }
@@ -65,7 +57,7 @@ export default function TransactionsPage() {
setDeletingId(id); setDeletingId(id);
try { try {
await api.transactions.delete(id); await api.transactions.delete(id);
await load(page, status); await refetch();
} finally { } finally {
setDeletingId(null); setDeletingId(null);
} }

View File

@@ -0,0 +1,18 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
},
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -11,6 +11,7 @@
"@heroui/react": "3.0.0-beta.8", "@heroui/react": "3.0.0-beta.8",
"@heroui/styles": "3.0.0-beta.8", "@heroui/styles": "3.0.0-beta.8",
"@internationalized/date": "^3.12.0", "@internationalized/date": "^3.12.0",
"@tanstack/react-query": "^5.90.21",
"better-auth": "^1.3.34", "better-auth": "^1.3.34",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",

View File

@@ -34,6 +34,7 @@
"oxlint": "^1.52.0" "oxlint": "^1.52.0"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.5.4" "@better-auth/passkey": "^1.5.4",
"@tanstack/react-query": "^5.90.21"
} }
} }