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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user