Files
helios-evcs/apps/web/app/dashboard/users/page.tsx
Timothy Yin 2cb89c74b3 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
2026-03-10 15:17:32 +08:00

364 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}