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:
2026-03-10 15:17:32 +08:00
parent 9a2668fae5
commit 2cb89c74b3
32 changed files with 4648 additions and 83 deletions

View 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>
);
}