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:
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