353 lines
10 KiB
TypeScript
353 lines
10 KiB
TypeScript
export class APIError extends Error {
|
||
constructor(
|
||
public readonly status: number,
|
||
message: string,
|
||
) {
|
||
super(message);
|
||
this.name = "APIError";
|
||
}
|
||
}
|
||
|
||
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
|
||
|
||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await fetch(`${CSMS_URL}${path}`, {
|
||
...init,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...init?.headers,
|
||
},
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => res.statusText);
|
||
throw new APIError(res.status, `API ${path} failed (${res.status}): ${text}`);
|
||
}
|
||
return res.json() as Promise<T>;
|
||
}
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────────────
|
||
|
||
export type Stats = {
|
||
totalChargePoints: number;
|
||
onlineChargePoints: number;
|
||
activeTransactions: number;
|
||
totalIdTags: number;
|
||
todayEnergyWh: number;
|
||
todayRevenue: number;
|
||
totalUsers: number;
|
||
todayTransactions: number;
|
||
};
|
||
|
||
export type UserStats = {
|
||
totalIdTags: number;
|
||
totalBalance: number;
|
||
activeTransactions: number;
|
||
totalTransactions: number;
|
||
todayEnergyWh: number;
|
||
todayTransactions: number;
|
||
};
|
||
|
||
export type ConnectorSummary = {
|
||
id: string;
|
||
connectorId: number;
|
||
status: string;
|
||
lastStatusAt: string | null;
|
||
};
|
||
|
||
export type ConnectorDetail = {
|
||
id: string;
|
||
connectorId: number;
|
||
status: string;
|
||
errorCode: string;
|
||
info: string | null;
|
||
vendorId: string | null;
|
||
vendorErrorCode: string | null;
|
||
lastStatusAt: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
};
|
||
|
||
export type ConnectionsStatus = {
|
||
connectedIdentifiers: string[];
|
||
};
|
||
|
||
export type ChargePoint = {
|
||
id: string;
|
||
chargePointIdentifier: string;
|
||
deviceName: string | null;
|
||
chargePointVendor: string | null;
|
||
chargePointModel: string | null;
|
||
registrationStatus: string;
|
||
lastHeartbeatAt: string | null;
|
||
lastBootNotificationAt: string | null;
|
||
feePerKwh: number;
|
||
pricingMode: "fixed" | "tou";
|
||
connectors: ConnectorSummary[];
|
||
chargePointStatus: string | null;
|
||
chargePointErrorCode: string | null;
|
||
};
|
||
|
||
export type ChargePointDetail = {
|
||
id: string;
|
||
chargePointIdentifier: string;
|
||
deviceName: string | null;
|
||
chargePointVendor: string | null;
|
||
chargePointModel: string | null;
|
||
chargePointSerialNumber: string | null;
|
||
firmwareVersion: string | null;
|
||
iccid: string | null;
|
||
imsi: string | null;
|
||
meterSerialNumber: string | null;
|
||
meterType: string | null;
|
||
registrationStatus: string;
|
||
heartbeatInterval: number | null;
|
||
lastHeartbeatAt: string | null;
|
||
lastBootNotificationAt: string | null;
|
||
feePerKwh: number;
|
||
pricingMode: "fixed" | "tou";
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
connectors: ConnectorDetail[];
|
||
chargePointStatus: string | null;
|
||
chargePointErrorCode: string | null;
|
||
};
|
||
|
||
export type Transaction = {
|
||
id: number;
|
||
chargePointIdentifier: string | null;
|
||
chargePointDeviceName: string | null;
|
||
connectorNumber: number | null;
|
||
idTag: string;
|
||
idTagStatus: string | null;
|
||
idTagUserId: string | null;
|
||
idTagUserName: string | null;
|
||
startTimestamp: string;
|
||
stopTimestamp: string | null;
|
||
startMeterValue: number | null;
|
||
stopMeterValue: number | null;
|
||
energyWh: number | null;
|
||
liveEnergyWh: number | null;
|
||
estimatedCost: number | null;
|
||
stopIdTag: string | null;
|
||
stopReason: string | null;
|
||
chargeAmount: number | null;
|
||
electricityFee: number | null;
|
||
serviceFee: number | null;
|
||
};
|
||
|
||
export type IdTag = {
|
||
idTag: string;
|
||
status: string;
|
||
expiryDate: string | null;
|
||
parentIdTag: string | null;
|
||
userId: string | null;
|
||
balance: number;
|
||
cardLayout: "center" | "around" | null;
|
||
cardSkin: "line" | "circles" | "glow" | "vip" | "redeye" | null;
|
||
createdAt: string;
|
||
};
|
||
|
||
export type UserRow = {
|
||
id: string;
|
||
name: string | null;
|
||
email: string;
|
||
emailVerified: boolean;
|
||
username: string | null;
|
||
role: string | null;
|
||
banned: boolean | null;
|
||
banReason: string | null;
|
||
createdAt: string;
|
||
};
|
||
|
||
export type ChargePointCreated = ChargePoint & {
|
||
/** 仅在创建时返回一次的明文密码,之后不可再查 */
|
||
plainPassword: string;
|
||
};
|
||
|
||
export type ChargePointPasswordReset = {
|
||
id: string;
|
||
chargePointIdentifier: string;
|
||
/** 仅在重置时返回一次的新明文密码 */
|
||
plainPassword: string;
|
||
};
|
||
|
||
export type PaginatedTransactions = {
|
||
data: Transaction[];
|
||
total: number;
|
||
page: number;
|
||
totalPages: number;
|
||
};
|
||
|
||
export type PriceTier = "peak" | "valley" | "flat";
|
||
|
||
export type TariffSlot = {
|
||
start: number;
|
||
end: number;
|
||
tier: PriceTier;
|
||
};
|
||
|
||
export type TierPricing = {
|
||
/** 电价(元/kWh) */
|
||
electricityPrice: number;
|
||
/** 服务费(元/kWh) */
|
||
serviceFee: number;
|
||
};
|
||
|
||
export type TariffConfig = {
|
||
id?: string;
|
||
slots: TariffSlot[];
|
||
prices: Record<PriceTier, TierPricing>;
|
||
createdAt?: string;
|
||
updatedAt?: string;
|
||
};
|
||
|
||
// ── API functions ──────────────────────────────────────────────────────────
|
||
|
||
export type ChartRange = "30d" | "7d" | "24h";
|
||
|
||
export type ChartDataPoint = {
|
||
bucket: string;
|
||
energyKwh: number;
|
||
revenue: number;
|
||
transactions: number;
|
||
utilizationPct: number;
|
||
};
|
||
|
||
export const api = {
|
||
stats: {
|
||
get: () => apiFetch<Stats | UserStats>("/api/stats"),
|
||
chart: (range: ChartRange) =>
|
||
apiFetch<ChartDataPoint[]>(`/api/stats/chart?range=${range}`),
|
||
},
|
||
chargePoints: {
|
||
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
||
get: (id: string) => apiFetch<ChargePointDetail>(`/api/charge-points/${id}`),
|
||
connections: () => apiFetch<ConnectionsStatus>("/api/charge-points/connections"),
|
||
create: (data: {
|
||
chargePointIdentifier: string;
|
||
chargePointVendor?: string;
|
||
chargePointModel?: string;
|
||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||
feePerKwh?: number;
|
||
pricingMode?: "fixed" | "tou";
|
||
deviceName?: string;
|
||
}) =>
|
||
apiFetch<ChargePointCreated>("/api/charge-points", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
}),
|
||
update: (
|
||
id: string,
|
||
data: {
|
||
feePerKwh?: number;
|
||
pricingMode?: "fixed" | "tou";
|
||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||
chargePointVendor?: string;
|
||
chargePointModel?: string;
|
||
deviceName?: string | null;
|
||
},
|
||
) =>
|
||
apiFetch<ChargePoint>(`/api/charge-points/${id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
}),
|
||
delete: (id: string) =>
|
||
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
|
||
resetPassword: (id: string) =>
|
||
apiFetch<ChargePointPasswordReset>(`/api/charge-points/${id}/reset-password`, {
|
||
method: "POST",
|
||
}),
|
||
},
|
||
transactions: {
|
||
list: (params?: {
|
||
page?: number;
|
||
limit?: number;
|
||
status?: "active" | "completed";
|
||
chargePointId?: string;
|
||
}) => {
|
||
const q = new URLSearchParams();
|
||
if (params?.page) q.set("page", String(params.page));
|
||
if (params?.limit) q.set("limit", String(params.limit));
|
||
if (params?.status) q.set("status", params.status);
|
||
if (params?.chargePointId) q.set("chargePointId", params.chargePointId);
|
||
const qs = q.toString();
|
||
return apiFetch<PaginatedTransactions>(`/api/transactions${qs ? "?" + qs : ""}`);
|
||
},
|
||
get: (id: number) => apiFetch<Transaction>(`/api/transactions/${id}`),
|
||
stop: (id: number) =>
|
||
apiFetch<Transaction & { online: boolean }>(`/api/transactions/${id}/stop`, {
|
||
method: "POST",
|
||
}),
|
||
remoteStart: (data: { chargePointIdentifier: string; connectorId: number; idTag: string }) =>
|
||
apiFetch<{ success: true }>("/api/transactions/remote-start", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
}),
|
||
delete: (id: number) =>
|
||
apiFetch<{ success: true }>(`/api/transactions/${id}`, { method: "DELETE" }),
|
||
},
|
||
idTags: {
|
||
list: () => apiFetch<IdTag[]>("/api/id-tags"),
|
||
get: (idTag: string) => apiFetch<IdTag>(`/api/id-tags/${idTag}`),
|
||
claim: () => apiFetch<IdTag>("/api/id-tags/claim", { method: "POST" }),
|
||
create: (data: {
|
||
idTag: string;
|
||
status?: string;
|
||
expiryDate?: string;
|
||
parentIdTag?: string;
|
||
userId?: string | null;
|
||
balance?: number;
|
||
cardLayout?: "center" | "around";
|
||
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye";
|
||
}) => apiFetch<IdTag>("/api/id-tags", { method: "POST", body: JSON.stringify(data) }),
|
||
update: (
|
||
idTag: string,
|
||
data: {
|
||
status?: string;
|
||
expiryDate?: string | null;
|
||
parentIdTag?: string | null;
|
||
userId?: string | null;
|
||
balance?: number;
|
||
cardLayout?: "center" | "around" | null;
|
||
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye" | null;
|
||
},
|
||
) => apiFetch<IdTag>(`/api/id-tags/${idTag}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||
delete: (idTag: string) =>
|
||
apiFetch<{ success: true }>(`/api/id-tags/${idTag}`, { method: "DELETE" }),
|
||
},
|
||
users: {
|
||
list: () => apiFetch<UserRow[]>("/api/users"),
|
||
create: (data: {
|
||
name: string;
|
||
email: string;
|
||
password: string;
|
||
username?: string;
|
||
role?: string;
|
||
}) =>
|
||
apiFetch<{ user: UserRow }>("/api/auth/admin/create-user", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
}),
|
||
update: (
|
||
id: string,
|
||
data: {
|
||
name?: string;
|
||
username?: string | null;
|
||
role?: string;
|
||
banned?: boolean;
|
||
banReason?: string | null;
|
||
},
|
||
) => apiFetch<UserRow>(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||
},
|
||
setup: {
|
||
create: (data: { name: string; email: string; username: string; password: string }) =>
|
||
apiFetch<{ success: boolean }>("/api/setup", { method: "POST", body: JSON.stringify(data) }),
|
||
},
|
||
tariff: {
|
||
get: () => apiFetch<TariffConfig | null>("/api/tariff"),
|
||
put: (data: { slots: TariffSlot[]; prices: Record<PriceTier, TierPricing> }) =>
|
||
apiFetch<TariffConfig>("/api/tariff", { method: "PUT", body: JSON.stringify(data) }),
|
||
},
|
||
};
|