feat(dashboard): add transactions and users management pages with CRUD functionality
feat(auth): implement login page and authentication middleware feat(sidebar): create sidebar component with user info and navigation links feat(api): establish API client for interacting with backend services
This commit is contained in:
275
apps/web/app/dashboard/charge-points/page.tsx
Normal file
275
apps/web/app/dashboard/charge-points/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Chip, Input, Label, Modal, Spinner, Table, TextField } from "@heroui/react";
|
||||
import { Pencil, TrashBin } from "@gravity-ui/icons";
|
||||
import { api, type ChargePoint } from "@/lib/api";
|
||||
|
||||
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
|
||||
Available: "success",
|
||||
Charging: "success",
|
||||
Occupied: "warning",
|
||||
Reserved: "warning",
|
||||
Faulted: "danger",
|
||||
Unavailable: "danger",
|
||||
Preparing: "warning",
|
||||
Finishing: "warning",
|
||||
SuspendedEV: "warning",
|
||||
SuspendedEVSE: "warning",
|
||||
};
|
||||
|
||||
const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
|
||||
Accepted: "success",
|
||||
Pending: "warning",
|
||||
Rejected: "danger",
|
||||
};
|
||||
|
||||
export default function ChargePointsPage() {
|
||||
const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]);
|
||||
const [editTarget, setEditTarget] = useState<ChargePoint | null>(null);
|
||||
const [feeInput, setFeeInput] = useState("0");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.chargePoints.list().catch(() => []);
|
||||
setChargePoints(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
load();
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const openEdit = (cp: ChargePoint) => {
|
||||
setEditTarget(cp);
|
||||
setFeeInput(String(cp.feePerKwh));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editTarget) return;
|
||||
const fee = Math.max(0, Math.round(Number(feeInput) || 0));
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.chargePoints.update(String(editTarget.id), { feePerKwh: fee });
|
||||
setChargePoints((prev) =>
|
||||
prev.map((cp) => (cp.id === editTarget.id ? { ...cp, feePerKwh: fee } : cp)),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.chargePoints.delete(String(deleteTarget.id));
|
||||
setChargePoints((prev) => prev.filter((cp) => cp.id !== deleteTarget.id));
|
||||
setDeleteTarget(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">充电桩管理</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">共 {chargePoints.length} 台设备</p>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="充电桩列表" className="min-w-200">
|
||||
<Table.Header>
|
||||
<Table.Column isRowHeader>标识符</Table.Column>
|
||||
<Table.Column>品牌 / 型号</Table.Column>
|
||||
<Table.Column>注册状态</Table.Column>
|
||||
<Table.Column>电价(分/kWh)</Table.Column>
|
||||
<Table.Column>最后心跳</Table.Column>
|
||||
<Table.Column>接口状态</Table.Column>
|
||||
<Table.Column>{""}</Table.Column>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{chargePoints.length === 0 && (
|
||||
<Table.Row id="empty">
|
||||
<Table.Cell>
|
||||
<span className="text-muted text-sm">暂无设备</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{""}</Table.Cell>
|
||||
<Table.Cell>{""}</Table.Cell>
|
||||
<Table.Cell>{""}</Table.Cell>
|
||||
<Table.Cell>{""}</Table.Cell>
|
||||
<Table.Cell>{""}</Table.Cell>
|
||||
<Table.Cell>{""}</Table.Cell>
|
||||
</Table.Row>
|
||||
)}
|
||||
{chargePoints.map((cp) => (
|
||||
<Table.Row key={cp.id} id={String(cp.id)}>
|
||||
<Table.Cell className="font-mono font-medium">
|
||||
{cp.chargePointIdentifier}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{cp.chargePointVendor && cp.chargePointModel ? (
|
||||
`${cp.chargePointVendor} / ${cp.chargePointModel}`
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Chip
|
||||
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{cp.registrationStatus}
|
||||
</Chip>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="tabular-nums">
|
||||
{cp.feePerKwh > 0 ? (
|
||||
<span>
|
||||
{cp.feePerKwh} 分
|
||||
<span className="ml-1 text-xs text-muted">
|
||||
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">免费</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{cp.lastHeartbeatAt ? (
|
||||
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cp.connectors.length === 0 ? (
|
||||
<span className="text-muted text-sm">—</span>
|
||||
) : (
|
||||
cp.connectors.map((conn) => (
|
||||
<Chip
|
||||
key={conn.id}
|
||||
color={statusColorMap[conn.status] ?? "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
#{conn.connectorId} {conn.status}
|
||||
</Chip>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => openEdit(cp)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-96">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>配置电价</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<p className="text-sm text-muted">
|
||||
充电桩:
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{cp.chargePointIdentifier}
|
||||
</span>
|
||||
</p>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">电价(分/kWh)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
value={feeInput}
|
||||
onChange={(e) => setFeeInput(e.target.value)}
|
||||
/>
|
||||
</TextField>
|
||||
<p className="text-xs text-muted">
|
||||
设为 0 则免费充电。当前:¥
|
||||
{((Number(feeInput) || 0) / 100).toFixed(2)}/kWh
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleSave}>
|
||||
{saving ? <Spinner size="sm" /> : "保存"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
onPress={() => setDeleteTarget(cp)}
|
||||
>
|
||||
<TrashBin className="size-4" />
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-96">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>确认删除充电桩</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-sm text-muted">
|
||||
将删除充电桩{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{cp.chargePointIdentifier}
|
||||
</span>
|
||||
及其所有连接器和充电记录,此操作不可恢复。
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
slot="close"
|
||||
variant="danger"
|
||||
isDisabled={deleting}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
{deleting ? <Spinner size="sm" /> : "确认删除"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
484
apps/web/app/dashboard/id-tags/page.tsx
Normal file
484
apps/web/app/dashboard/id-tags/page.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Autocomplete,
|
||||
Button,
|
||||
Calendar,
|
||||
Chip,
|
||||
DateField,
|
||||
DatePicker,
|
||||
EmptyState,
|
||||
Input,
|
||||
Label,
|
||||
ListBox,
|
||||
Modal,
|
||||
SearchField,
|
||||
Select,
|
||||
Spinner,
|
||||
Table,
|
||||
TextField,
|
||||
useFilter,
|
||||
} from "@heroui/react";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import { Pencil, TrashBin } from "@gravity-ui/icons";
|
||||
import { api, type IdTag, type UserRow } from "@/lib/api";
|
||||
|
||||
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
|
||||
Accepted: "success",
|
||||
Blocked: "danger",
|
||||
Expired: "warning",
|
||||
Invalid: "danger",
|
||||
ConcurrentTx: "warning",
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
idTag: string;
|
||||
status: string;
|
||||
expiryDate: string;
|
||||
parentIdTag: string;
|
||||
userId: string;
|
||||
balance: string;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
idTag: "",
|
||||
status: "Accepted",
|
||||
expiryDate: "",
|
||||
parentIdTag: "",
|
||||
userId: "",
|
||||
balance: "0",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const;
|
||||
|
||||
/** 将「元」字符串转为分(整数),无效时返回 0 */
|
||||
function yuanToFen(yuan: string): number {
|
||||
const n = parseFloat(yuan);
|
||||
return isNaN(n) ? 0 : Math.round(n * 100);
|
||||
}
|
||||
|
||||
/** 将分(整数)格式化为「元」字符串 */
|
||||
function fenToYuan(fen: number): string {
|
||||
return (fen / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function UserAutocomplete({
|
||||
userId,
|
||||
onChange,
|
||||
users,
|
||||
}: {
|
||||
userId: string;
|
||||
onChange: (id: string) => void;
|
||||
users: UserRow[];
|
||||
}) {
|
||||
const { contains } = useFilter({ sensitivity: "base" });
|
||||
return (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
placeholder="搜索用户…"
|
||||
selectionMode="single"
|
||||
value={userId || null}
|
||||
onChange={(key) => onChange(key ? String(key) : "")}
|
||||
>
|
||||
<Autocomplete.Trigger>
|
||||
<Autocomplete.Value>
|
||||
{({ isPlaceholder, state }: any) => {
|
||||
if (isPlaceholder || !state.selectedItems?.length)
|
||||
return <span className="text-muted">不分配</span>;
|
||||
const u = users.find((u) => u.id === state.selectedItems[0]?.key);
|
||||
return u ? <span>{u.name ?? u.username ?? u.email}</span> : null;
|
||||
}}
|
||||
</Autocomplete.Value>
|
||||
<Autocomplete.ClearButton />
|
||||
<Autocomplete.Indicator />
|
||||
</Autocomplete.Trigger>
|
||||
<Autocomplete.Popover>
|
||||
<Autocomplete.Filter filter={contains}>
|
||||
<SearchField autoFocus name="userSearch" variant="secondary">
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input placeholder="搜索姓名或邮箱…" />
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
<ListBox renderEmptyState={() => <EmptyState>无匹配用户</EmptyState>}>
|
||||
{users.map((u) => (
|
||||
<ListBox.Item
|
||||
key={u.id}
|
||||
id={u.id}
|
||||
textValue={`${u.name ?? u.username ?? ""} ${u.email}`}
|
||||
>
|
||||
<span className="font-medium">{u.name ?? u.username ?? u.email}</span>
|
||||
<span className="ml-1.5 text-xs text-muted">{u.email}</span>
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Autocomplete.Filter>
|
||||
</Autocomplete.Popover>
|
||||
</Autocomplete>
|
||||
);
|
||||
}
|
||||
|
||||
function TagFormBody({
|
||||
form,
|
||||
setForm,
|
||||
isEdit,
|
||||
users,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (f: FormState) => void;
|
||||
isEdit: boolean;
|
||||
users: UserRow[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">{isEdit ? "卡号" : "卡号 (idTag)"}</Label>
|
||||
<Input
|
||||
disabled={isEdit}
|
||||
className="font-mono"
|
||||
placeholder="e.g. RFID001"
|
||||
value={form.idTag}
|
||||
onChange={(e) => setForm({ ...form, idTag: e.target.value })}
|
||||
/>
|
||||
</TextField>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">状态</Label>
|
||||
<Select
|
||||
fullWidth
|
||||
selectedKey={form.status}
|
||||
onSelectionChange={(key) => setForm({ ...form, status: String(key) })}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<ListBox.Item key={s} id={s}>
|
||||
{s}
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">{isEdit ? "有效期" : "有效期 (可选)"}</Label>
|
||||
<DatePicker
|
||||
value={form.expiryDate ? parseDate(form.expiryDate) : null}
|
||||
onChange={(date) => setForm({ ...form, expiryDate: date ? date.toString() : "" })}
|
||||
>
|
||||
<DateField.Group fullWidth>
|
||||
<DateField.InputContainer>
|
||||
<DateField.Input>
|
||||
{(segment) => <DateField.Segment segment={segment} />}
|
||||
</DateField.Input>
|
||||
</DateField.InputContainer>
|
||||
<DateField.Suffix>
|
||||
<DatePicker.Trigger>
|
||||
<DatePicker.TriggerIndicator />
|
||||
</DatePicker.Trigger>
|
||||
</DateField.Suffix>
|
||||
</DateField.Group>
|
||||
<DatePicker.Popover>
|
||||
<Calendar>
|
||||
<Calendar.Header>
|
||||
<Calendar.NavButton slot="previous" />
|
||||
<Calendar.Heading />
|
||||
<Calendar.NavButton slot="next" />
|
||||
</Calendar.Header>
|
||||
<Calendar.Grid>
|
||||
<Calendar.GridHeader>
|
||||
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
|
||||
</Calendar.GridHeader>
|
||||
<Calendar.GridBody>
|
||||
{(date) => (
|
||||
<Calendar.Cell date={date}>
|
||||
{({ formattedDate }) => (
|
||||
<>
|
||||
{formattedDate}
|
||||
<Calendar.CellIndicator />
|
||||
</>
|
||||
)}
|
||||
</Calendar.Cell>
|
||||
)}
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
</Calendar>
|
||||
</DatePicker.Popover>
|
||||
</DatePicker>
|
||||
</div>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">{isEdit ? "父卡号" : "父卡号 (可选)"}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="parentIdTag"
|
||||
value={form.parentIdTag}
|
||||
onChange={(e) => setForm({ ...form, parentIdTag: e.target.value })}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">余额(元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={form.balance}
|
||||
onChange={(e) => setForm({ ...form, balance: e.target.value })}
|
||||
/>
|
||||
</TextField>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">关联用户(可选)</Label>
|
||||
<UserAutocomplete
|
||||
userId={form.userId}
|
||||
onChange={(id) => setForm({ ...form, userId: id })}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IdTagsPage() {
|
||||
const [tags, setTags] = useState<IdTag[]>([]);
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<IdTag | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingTag, setDeletingTag] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tagList, userList] = await Promise.all([
|
||||
api.idTags.list(),
|
||||
api.users.list().catch(() => [] as UserRow[]),
|
||||
]);
|
||||
setTags(tagList);
|
||||
setUsers(userList);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const openEdit = (tag: IdTag) => {
|
||||
setEditing(tag);
|
||||
setForm({
|
||||
idTag: tag.idTag,
|
||||
status: tag.status,
|
||||
expiryDate: tag.expiryDate ? tag.expiryDate.slice(0, 10) : "",
|
||||
parentIdTag: tag.parentIdTag ?? "",
|
||||
userId: tag.userId ?? "",
|
||||
balance: fenToYuan(tag.balance),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.idTags.update(editing.idTag, {
|
||||
status: form.status,
|
||||
expiryDate: form.expiryDate || null,
|
||||
parentIdTag: form.parentIdTag || null,
|
||||
userId: form.userId || null,
|
||||
balance: yuanToFen(form.balance),
|
||||
});
|
||||
} else {
|
||||
await api.idTags.create({
|
||||
idTag: form.idTag,
|
||||
status: form.status,
|
||||
expiryDate: form.expiryDate || undefined,
|
||||
parentIdTag: form.parentIdTag || undefined,
|
||||
userId: form.userId || undefined,
|
||||
balance: yuanToFen(form.balance),
|
||||
});
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (idTag: string) => {
|
||||
setDeletingTag(idTag);
|
||||
try {
|
||||
await api.idTags.delete(idTag);
|
||||
await load();
|
||||
} finally {
|
||||
setDeletingTag(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">储值卡管理</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">共 {tags.length} 张</p>
|
||||
</div>
|
||||
<Modal>
|
||||
<Button variant="secondary" onPress={openCreate}>
|
||||
新增储值卡
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>新增储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TagFormBody form={form} setForm={setForm} isEdit={false} users={users} />
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleSave}>
|
||||
{saving ? <Spinner size="sm" /> : "创建"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="储值卡列表" className="min-w-200">
|
||||
<Table.Header>
|
||||
<Table.Column isRowHeader>卡号</Table.Column>
|
||||
<Table.Column>状态</Table.Column>
|
||||
<Table.Column>余额</Table.Column>
|
||||
<Table.Column>关联用户</Table.Column>
|
||||
<Table.Column>有效期</Table.Column>
|
||||
<Table.Column>父卡号</Table.Column>
|
||||
<Table.Column>创建时间</Table.Column>
|
||||
<Table.Column className="text-end">操作</Table.Column>
|
||||
</Table.Header>
|
||||
<Table.Body
|
||||
renderEmptyState={() => (
|
||||
<div className="py-8 text-center text-sm text-muted">
|
||||
{loading ? "加载中…" : "暂无储值卡"}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{tags.map((tag) => {
|
||||
const owner = users.find((u) => u.id === tag.userId);
|
||||
return (
|
||||
<Table.Row key={tag.idTag} id={tag.idTag}>
|
||||
<Table.Cell className="font-mono font-medium">{tag.idTag}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Chip
|
||||
color={statusColorMap[tag.status] ?? "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{tag.status}
|
||||
</Chip>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{owner ? (
|
||||
<span title={owner.email}>
|
||||
{owner.name ?? owner.username ?? owner.email}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{tag.expiryDate ? (
|
||||
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="font-mono">
|
||||
{tag.parentIdTag ?? <span className="text-muted">—</span>}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{new Date(tag.createdAt).toLocaleString("zh-CN")}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* Edit button */}
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => openEdit(tag)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>编辑储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TagFormBody
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
isEdit={true}
|
||||
users={users}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleSave}>
|
||||
{saving ? <Spinner size="sm" /> : "保存"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
{/* Delete button */}
|
||||
<Button
|
||||
isDisabled={deletingTag === tag.idTag}
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
onPress={() => handleDelete(tag.idTag)}
|
||||
>
|
||||
{deletingTag === tag.idTag ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<TrashBin className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/web/app/dashboard/layout.tsx
Normal file
19
apps/web/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from 'react'
|
||||
import Sidebar from '@/components/sidebar'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-dvh bg-background">
|
||||
<Sidebar />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
apps/web/app/dashboard/page.tsx
Normal file
126
apps/web/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Card } from "@heroui/react";
|
||||
import { Thunderbolt, PlugConnection, CreditCard, ChartColumn } from "@gravity-ui/icons";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type CardColor = "accent" | "success" | "warning" | "default";
|
||||
|
||||
const colorStyles: Record<CardColor, { border: string; bg: string; icon: string }> = {
|
||||
accent: { border: "border-accent", bg: "bg-accent/10", icon: "text-accent" },
|
||||
success: { border: "border-success", bg: "bg-success/10", icon: "text-success" },
|
||||
warning: { border: "border-warning", bg: "bg-warning/10", icon: "text-warning" },
|
||||
default: { border: "border-border", bg: "bg-default", icon: "text-muted" },
|
||||
};
|
||||
|
||||
function StatusDot({ color }: { color: "success" | "warning" | "muted" }) {
|
||||
const cls =
|
||||
color === "success" ? "bg-success" : color === "warning" ? "bg-warning" : "bg-muted/40";
|
||||
return <span className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${cls}`} />;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
footer,
|
||||
icon: Icon,
|
||||
color = "default",
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
footer?: React.ReactNode;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
color?: CardColor;
|
||||
}) {
|
||||
const s = colorStyles[color];
|
||||
return (
|
||||
<Card className={`border-t-2 ${s.border}`}>
|
||||
<Card.Content className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm text-muted">{title}</p>
|
||||
{Icon && (
|
||||
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
|
||||
<Icon className={`size-4.5 ${s.icon}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-3xl font-bold tabular-nums leading-none text-foreground">{value}</p>
|
||||
{footer && (
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const stats = await api.stats.get().catch(() => null);
|
||||
|
||||
const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—";
|
||||
const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">概览</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">实时运营状态</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<StatCard
|
||||
title="充电桩总数"
|
||||
value={stats?.totalChargePoints ?? "—"}
|
||||
icon={PlugConnection}
|
||||
color="accent"
|
||||
footer={
|
||||
<>
|
||||
<StatusDot color="success" />
|
||||
<span className="font-medium text-success">
|
||||
{stats?.onlineChargePoints ?? 0} 在线
|
||||
</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>{offlineCount} 离线</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="在线充电桩"
|
||||
value={stats?.onlineChargePoints ?? "—"}
|
||||
icon={PlugConnection}
|
||||
color="success"
|
||||
footer={<span>最近 2 分钟有心跳</span>}
|
||||
/>
|
||||
<StatCard
|
||||
title="进行中充电"
|
||||
value={stats?.activeTransactions ?? "—"}
|
||||
icon={Thunderbolt}
|
||||
color={stats?.activeTransactions ? "warning" : "default"}
|
||||
footer={
|
||||
<>
|
||||
<StatusDot color={stats?.activeTransactions ? "success" : "muted"} />
|
||||
<span className={stats?.activeTransactions ? "font-medium text-success" : ""}>
|
||||
{stats?.activeTransactions ? "活跃中" : "当前空闲"}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="储值卡总数"
|
||||
value={stats?.totalIdTags ?? "—"}
|
||||
icon={CreditCard}
|
||||
color="default"
|
||||
footer={<span>已注册卡片总量</span>}
|
||||
/>
|
||||
<StatCard
|
||||
title="今日充电量"
|
||||
value={`${todayKwh} kWh`}
|
||||
icon={ChartColumn}
|
||||
color="accent"
|
||||
footer={<span>当日 00:00 起累计</span>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
apps/web/app/dashboard/transactions/page.tsx
Normal file
298
apps/web/app/dashboard/transactions/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
|
||||
import { TrashBin } from "@gravity-ui/icons";
|
||||
import { api, type PaginatedTransactions } from "@/lib/api";
|
||||
|
||||
const LIMIT = 15;
|
||||
|
||||
function formatDuration(start: string, stop: string | null): string {
|
||||
if (!stop) return "进行中";
|
||||
const ms = new Date(stop).getTime() - new Date(start).getTime();
|
||||
const min = Math.floor(ms / 60000);
|
||||
if (min < 60) return `${min} 分钟`;
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [data, setData] = useState<PaginatedTransactions | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stoppingId, setStoppingId] = useState<number | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async (p: number, s: typeof status) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.transactions.list({
|
||||
page: p,
|
||||
limit: LIMIT,
|
||||
status: s === "all" ? undefined : s,
|
||||
});
|
||||
setData(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(page, status);
|
||||
}, [page, status, load]);
|
||||
|
||||
const handleStatusChange = (s: typeof status) => {
|
||||
setStatus(s);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleStop = async (id: number) => {
|
||||
setStoppingId(id);
|
||||
try {
|
||||
await api.transactions.stop(id);
|
||||
await load(page, status);
|
||||
} finally {
|
||||
setStoppingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await api.transactions.delete(id);
|
||||
await load(page, status);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pages = data ? Array.from({ length: data.totalPages }, (_, i) => i + 1) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">充电记录</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">共 {data?.total ?? "—"} 条</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
|
||||
{(["all", "active", "completed"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => handleStatusChange(s)}
|
||||
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
|
||||
status === s
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="充电记录" className="min-w-200">
|
||||
<Table.Header>
|
||||
<Table.Column isRowHeader>ID</Table.Column>
|
||||
<Table.Column>充电桩</Table.Column>
|
||||
<Table.Column>接口</Table.Column>
|
||||
<Table.Column>储值卡</Table.Column>
|
||||
<Table.Column>状态</Table.Column>
|
||||
<Table.Column>开始时间</Table.Column>
|
||||
<Table.Column>时长</Table.Column>
|
||||
<Table.Column>电量 (kWh)</Table.Column>
|
||||
<Table.Column>费用 (元)</Table.Column>
|
||||
<Table.Column>停止原因</Table.Column>
|
||||
<Table.Column>{""}</Table.Column>
|
||||
</Table.Header>
|
||||
<Table.Body
|
||||
renderEmptyState={() => (
|
||||
<div className="py-8 text-center text-sm text-muted">
|
||||
{loading ? "加载中…" : "暂无记录"}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{(data?.data ?? []).map((tx) => (
|
||||
<Table.Row key={tx.id} id={tx.id}>
|
||||
<Table.Cell className="font-mono text-sm">{tx.id}</Table.Cell>
|
||||
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</Table.Cell>
|
||||
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
|
||||
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{tx.stopTimestamp ? (
|
||||
<Chip color="success" size="sm" variant="soft">
|
||||
已完成
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip color="warning" size="sm" variant="soft">
|
||||
进行中
|
||||
</Chip>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="whitespace-nowrap text-sm">
|
||||
{new Date(tx.startTimestamp).toLocaleString("zh-CN")}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="tabular-nums">
|
||||
{tx.energyWh != null ? (tx.energyWh / 1000).toFixed(3) : "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="tabular-nums">
|
||||
{tx.chargeAmount != null ? `¥${(tx.chargeAmount / 100).toFixed(2)}` : "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{tx.stopReason ? (
|
||||
<Chip color="default" size="sm" variant="soft">
|
||||
{tx.stopReason}
|
||||
</Chip>
|
||||
) : tx.stopTimestamp ? (
|
||||
<Chip color="default" size="sm" variant="soft">
|
||||
Local
|
||||
</Chip>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
{!tx.stopTimestamp && (
|
||||
<Modal>
|
||||
<Button size="sm" variant="danger-soft" isDisabled={stoppingId === tx.id}>
|
||||
{stoppingId === tx.id ? <Spinner size="sm" /> : "中止"}
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-96">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>确认中止充电</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-sm text-muted">
|
||||
将强制结束交易{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
#{tx.id}
|
||||
</span>
|
||||
(储值卡:<span className="font-mono">{tx.idTag}</span>)。
|
||||
如果充电桩在线,将发送远程结算指令。
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
slot="close"
|
||||
variant="danger"
|
||||
isDisabled={stoppingId === tx.id}
|
||||
onPress={() => handleStop(tx.id)}
|
||||
>
|
||||
{stoppingId === tx.id ? <Spinner size="sm" /> : "确认中止"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
)}
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={deletingId === tx.id}
|
||||
>
|
||||
{deletingId === tx.id ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<TrashBin className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-96">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>确认删除记录</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-sm text-muted">
|
||||
将永久删除充电记录{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
#{tx.id}
|
||||
</span>
|
||||
(储值卡:<span className="font-mono">{tx.idTag}</span>)。
|
||||
{!tx.stopTimestamp && "该记录仍进行中,删除同时将重置接口状态。"}
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
slot="close"
|
||||
variant="danger"
|
||||
isDisabled={deletingId === tx.id}
|
||||
onPress={() => handleDelete(tx.id)}
|
||||
>
|
||||
{deletingId === tx.id ? <Spinner size="sm" /> : "确认删除"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
{data && data.totalPages > 1 && (
|
||||
<Table.Footer>
|
||||
<Pagination size="sm">
|
||||
<Pagination.Summary>
|
||||
第 {(page - 1) * LIMIT + 1}–{Math.min(page * LIMIT, data.total)} 条,共 {data.total}{" "}
|
||||
条
|
||||
</Pagination.Summary>
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.Previous
|
||||
isDisabled={page === 1}
|
||||
onPress={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<Pagination.PreviousIcon />
|
||||
上一页
|
||||
</Pagination.Previous>
|
||||
</Pagination.Item>
|
||||
{pages.map((p) => (
|
||||
<Pagination.Item key={p}>
|
||||
<Pagination.Link isActive={p === page} onPress={() => setPage(p)}>
|
||||
{p}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
))}
|
||||
<Pagination.Item>
|
||||
<Pagination.Next
|
||||
isDisabled={page === data.totalPages}
|
||||
onPress={() => setPage((p) => Math.min(data.totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
<Pagination.NextIcon />
|
||||
</Pagination.Next>
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
</Pagination>
|
||||
</Table.Footer>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
apps/web/app/dashboard/users/page.tsx
Normal file
363
apps/web/app/dashboard/users/page.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Input,
|
||||
Label,
|
||||
ListBox,
|
||||
Modal,
|
||||
Select,
|
||||
Spinner,
|
||||
Table,
|
||||
TextField,
|
||||
} from "@heroui/react";
|
||||
import { Pencil } from "@gravity-ui/icons";
|
||||
import { api, type UserRow } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
|
||||
type CreateForm = {
|
||||
name: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
type EditForm = {
|
||||
name: string;
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
const emptyCreate: CreateForm = { name: "", email: "", username: "", password: "", role: "user" };
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ key: "user", label: "用户" },
|
||||
{ key: "admin", label: "管理员" },
|
||||
];
|
||||
|
||||
export default function UsersPage() {
|
||||
const { data: session } = useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
const [createForm, setCreateForm] = useState<CreateForm>(emptyCreate);
|
||||
const [editTarget, setEditTarget] = useState<UserRow | null>(null);
|
||||
const [editForm, setEditForm] = useState<EditForm>({ name: "", username: "", role: "user" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setUsers(await api.users.list());
|
||||
} catch {
|
||||
// possibly not admin — show empty
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const openEdit = (u: UserRow) => {
|
||||
setEditTarget(u);
|
||||
setEditForm({ name: u.name ?? "", username: u.username ?? "", role: u.role ?? "user" });
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.users.create({
|
||||
name: createForm.name,
|
||||
email: createForm.email,
|
||||
password: createForm.password,
|
||||
username: createForm.username || undefined,
|
||||
role: createForm.role,
|
||||
});
|
||||
setCreateForm(emptyCreate);
|
||||
await load();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!editTarget) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.users.update(editTarget.id, {
|
||||
name: editForm.name || undefined,
|
||||
username: editForm.username || null,
|
||||
role: editForm.role,
|
||||
});
|
||||
await load();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBan = async (u: UserRow) => {
|
||||
setUpdating(u.id);
|
||||
try {
|
||||
await api.users.update(u.id, {
|
||||
banned: !u.banned,
|
||||
banReason: u.banned ? null : "管理员封禁",
|
||||
});
|
||||
await load();
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEditingSelf = editTarget?.id === currentUserId;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">用户管理</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">共 {users.length} 位用户(仅管理员可见)</p>
|
||||
</div>
|
||||
<Modal>
|
||||
<Button variant="secondary" onPress={() => setCreateForm(emptyCreate)}>
|
||||
新增用户
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>新增用户</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">姓名</Label>
|
||||
<Input
|
||||
placeholder="用户姓名"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="user@example.com"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">用户名(可选)</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="username"
|
||||
value={createForm.username}
|
||||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||||
/>
|
||||
</TextField>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">角色</Label>
|
||||
<Select
|
||||
fullWidth
|
||||
selectedKey={createForm.role}
|
||||
onSelectionChange={(key) =>
|
||||
setCreateForm({ ...createForm, role: String(key) })
|
||||
}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<ListBox.Item key={r.key} id={r.key}>
|
||||
{r.label}
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={
|
||||
saving || !createForm.name || !createForm.email || !createForm.password
|
||||
}
|
||||
slot="close"
|
||||
onPress={handleCreate}
|
||||
>
|
||||
{saving ? <Spinner size="sm" /> : "创建"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="用户列表" className="min-w-187.5">
|
||||
<Table.Header>
|
||||
<Table.Column isRowHeader>用户</Table.Column>
|
||||
<Table.Column>邮箱</Table.Column>
|
||||
<Table.Column>用户名</Table.Column>
|
||||
<Table.Column>角色</Table.Column>
|
||||
<Table.Column>状态</Table.Column>
|
||||
<Table.Column>注册时间</Table.Column>
|
||||
<Table.Column className="text-end">操作</Table.Column>
|
||||
</Table.Header>
|
||||
<Table.Body
|
||||
renderEmptyState={() => (
|
||||
<div className="py-8 text-center text-sm text-muted">
|
||||
{loading ? "加载中…" : "暂无用户或无权限"}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{users.map((u) => (
|
||||
<Table.Row key={u.id} id={u.id}>
|
||||
<Table.Cell>
|
||||
<div className="font-medium">{u.name ?? "—"}</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">{u.email}</Table.Cell>
|
||||
<Table.Cell className="font-mono text-sm">{u.username ?? "—"}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Chip
|
||||
color={u.role === "admin" ? "success" : "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{u.role === "admin" ? "管理员" : "用户"}
|
||||
</Chip>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{u.banned ? (
|
||||
<Chip color="danger" size="sm" variant="soft">
|
||||
已封禁
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip color="success" size="sm" variant="soft">
|
||||
正常
|
||||
</Chip>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{new Date(u.createdAt).toLocaleString("zh-CN")}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* Edit button */}
|
||||
<Modal>
|
||||
<Button isIconOnly size="sm" variant="tertiary" onPress={() => openEdit(u)}>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>编辑用户</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">姓名</Label>
|
||||
<Input
|
||||
placeholder="用户姓名"
|
||||
value={editForm.name}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">用户名</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="username"
|
||||
value={editForm.username}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, username: e.target.value })
|
||||
}
|
||||
/>
|
||||
</TextField>
|
||||
{!isEditingSelf && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">角色</Label>
|
||||
<Select
|
||||
fullWidth
|
||||
selectedKey={editForm.role}
|
||||
onSelectionChange={(key) =>
|
||||
setEditForm({ ...editForm, role: String(key) })
|
||||
}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<ListBox.Item key={r.key} id={r.key}>
|
||||
{r.label}
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleEdit}>
|
||||
{saving ? <Spinner size="sm" /> : "保存"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
{/* Ban / Unban button — disabled for current account */}
|
||||
<Button
|
||||
isDisabled={u.id === currentUserId || updating === u.id}
|
||||
size="sm"
|
||||
variant={u.banned ? "tertiary" : "danger-soft"}
|
||||
onPress={() => toggleBan(u)}
|
||||
>
|
||||
{updating === u.id ? <Spinner size="sm" /> : u.banned ? "解封" : "封禁"}
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
apps/web/app/login/page.tsx
Normal file
96
apps/web/app/login/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
|
||||
import { Thunderbolt } from "@gravity-ui/icons";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await authClient.signIn.username({
|
||||
username,
|
||||
password,
|
||||
fetchOptions: { credentials: "include" },
|
||||
});
|
||||
if (res.error) {
|
||||
setError(res.error.message ?? "登录失败,请检查用户名和密码");
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
setError("网络错误,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-4">
|
||||
{/* Brand */}
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-accent shadow-lg">
|
||||
<Thunderbolt className="size-7 text-accent-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Helios EVCS</h1>
|
||||
<p className="mt-1 text-sm text-muted">电动车充电站管理系统</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="w-full max-w-sm">
|
||||
<Card.Content>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">用户名</Label>
|
||||
<Input
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="admin"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">密码</Label>
|
||||
<Input
|
||||
required
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</TextField>
|
||||
{error && (
|
||||
<Alert status="danger">
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>登录失败</Alert.Title>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
<CloseButton onPress={() => setError("")} />
|
||||
</Alert>
|
||||
)}
|
||||
<Button className="mt-2 w-full" isDisabled={loading} type="submit">
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<p className="mt-6 text-xs text-muted/60">OCPP 1.6-J Protocol • v0.1.0</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,5 @@
|
||||
import { Button } from "@heroui/react";
|
||||
import Image from "next/image";
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
<Button>Hello</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
48
apps/web/components/sidebar-footer.tsx
Normal file
48
apps/web/components/sidebar-footer.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ArrowRightFromSquare, PersonFill } from '@gravity-ui/icons'
|
||||
import { signOut, useSession } from '@/lib/auth-client'
|
||||
|
||||
export default function SidebarFooter() {
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut({ fetchOptions: { credentials: 'include' } })
|
||||
router.push('/login')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border p-3">
|
||||
{/* User info */}
|
||||
{session?.user && (
|
||||
<div className="mb-2 flex items-center gap-2.5 rounded-lg px-2 py-1.5">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-accent-soft">
|
||||
<PersonFill className="size-3.5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
{session.user.name || session.user.email}
|
||||
</p>
|
||||
<p className="truncate text-xs leading-tight text-muted capitalize">
|
||||
{session.user.role ?? 'user'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm text-muted transition-colors hover:bg-surface-tertiary hover:text-foreground"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<ArrowRightFromSquare className="size-4 shrink-0" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-2 px-2 text-[11px] text-muted/60">OCPP 1.6-J • v0.1.0</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
apps/web/components/sidebar.tsx
Normal file
124
apps/web/components/sidebar.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { CreditCard, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons'
|
||||
import SidebarFooter from '@/components/sidebar-footer'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true },
|
||||
{ href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection },
|
||||
{ href: '/dashboard/transactions', label: '充电记录', icon: ListCheck },
|
||||
{ href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard },
|
||||
{ href: '/dashboard/users', label: '用户管理', icon: Person },
|
||||
]
|
||||
|
||||
function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) {
|
||||
return (
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-5">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-accent">
|
||||
<Thunderbolt className="size-4 text-accent-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold tracking-tight text-foreground">Helios EVCS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3">
|
||||
<p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
|
||||
管理
|
||||
</p>
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.exact
|
||||
? pathname === item.href
|
||||
: pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={[
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-muted hover:bg-surface-tertiary hover:text-foreground',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
{isActive && (
|
||||
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<SidebarFooter />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile top bar */}
|
||||
<div className="fixed inset-x-0 top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-surface-secondary px-4 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted transition-colors hover:bg-surface-tertiary hover:text-foreground"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<Bars className="size-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-6 items-center justify-center rounded-md bg-accent">
|
||||
<Thunderbolt className="size-3.5 text-accent-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold">Helios EVCS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
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',
|
||||
open ? 'translate-x-0' : '-translate-x-full',
|
||||
].join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3 flex size-8 items-center justify-center rounded-lg text-muted transition-colors hover:bg-surface-tertiary hover:text-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="关闭菜单"
|
||||
>
|
||||
<Xmark className="size-4" />
|
||||
</button>
|
||||
<NavContent pathname={pathname} onNavigate={() => setOpen(false)} />
|
||||
</aside>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface-secondary lg:flex">
|
||||
<NavContent pathname={pathname} />
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
175
apps/web/lib/api.ts
Normal file
175
apps/web/lib/api.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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 Error(`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;
|
||||
};
|
||||
|
||||
export type ConnectorSummary = {
|
||||
id: number;
|
||||
connectorId: number;
|
||||
status: string;
|
||||
lastStatusAt: string | null;
|
||||
};
|
||||
|
||||
export type ChargePoint = {
|
||||
id: number;
|
||||
chargePointIdentifier: string;
|
||||
chargePointVendor: string | null;
|
||||
chargePointModel: string | null;
|
||||
registrationStatus: string;
|
||||
lastHeartbeatAt: string | null;
|
||||
lastBootNotificationAt: string | null;
|
||||
feePerKwh: number;
|
||||
connectors: ConnectorSummary[];
|
||||
};
|
||||
|
||||
export type Transaction = {
|
||||
id: number;
|
||||
chargePointIdentifier: string | null;
|
||||
connectorNumber: number | null;
|
||||
idTag: string;
|
||||
idTagStatus: string | null;
|
||||
startTimestamp: string;
|
||||
stopTimestamp: string | null;
|
||||
startMeterValue: number | null;
|
||||
stopMeterValue: number | null;
|
||||
energyWh: number | null;
|
||||
stopIdTag: string | null;
|
||||
stopReason: string | null;
|
||||
chargeAmount: number | null;
|
||||
};
|
||||
|
||||
export type IdTag = {
|
||||
idTag: string;
|
||||
status: string;
|
||||
expiryDate: string | null;
|
||||
parentIdTag: string | null;
|
||||
userId: string | null;
|
||||
balance: number;
|
||||
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 PaginatedTransactions = {
|
||||
data: Transaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
// ── API functions ──────────────────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
stats: {
|
||||
get: () => apiFetch<Stats>("/api/stats"),
|
||||
},
|
||||
chargePoints: {
|
||||
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
||||
get: (id: number) => apiFetch<ChargePoint>(`/api/charge-points/${id}`),
|
||||
update: (id: string, data: { feePerKwh: number }) =>
|
||||
apiFetch<{ feePerKwh: number }>(`/api/charge-points/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (id: string) =>
|
||||
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
transactions: {
|
||||
list: (params?: { page?: number; limit?: number; status?: "active" | "completed" }) => {
|
||||
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);
|
||||
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",
|
||||
}),
|
||||
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}`),
|
||||
create: (data: {
|
||||
idTag: string;
|
||||
status?: string;
|
||||
expiryDate?: string;
|
||||
parentIdTag?: string;
|
||||
userId?: string | null;
|
||||
balance?: number;
|
||||
}) => 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;
|
||||
},
|
||||
) => 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) }),
|
||||
},
|
||||
};
|
||||
9
apps/web/lib/auth-client.ts
Normal file
9
apps/web/lib/auth-client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { adminClient, usernameClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001",
|
||||
plugins: [usernameClient(), adminClient()],
|
||||
});
|
||||
|
||||
export const { signIn, signOut, signUp, useSession } = authClient;
|
||||
27
apps/web/middleware.ts
Normal file
27
apps/web/middleware.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// 只保护 /dashboard 路由
|
||||
if (!pathname.startsWith("/dashboard")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 检查 better-auth session cookie(cookie 前缀是 helios_auth)
|
||||
const sessionCookie =
|
||||
request.cookies.get("helios_auth.session_token") ??
|
||||
request.cookies.get("__Secure-helios_auth.session_token");
|
||||
|
||||
if (!sessionCookie) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("from", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/dashboard/:path*"],
|
||||
};
|
||||
@@ -10,11 +10,14 @@
|
||||
"dependencies": {
|
||||
"@heroui/react": "3.0.0-beta.8",
|
||||
"@heroui/styles": "3.0.0-beta.8",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"better-auth": "^1.3.34",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gravity-ui/icons": "^2.18.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
|
||||
Reference in New Issue
Block a user