676 lines
27 KiB
TypeScript
676 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import {
|
||
Autocomplete,
|
||
Button,
|
||
Chip,
|
||
EmptyState,
|
||
Input,
|
||
Label,
|
||
ListBox,
|
||
Modal,
|
||
SearchField,
|
||
Select,
|
||
Spinner,
|
||
Table,
|
||
TextField,
|
||
useFilter,
|
||
} from "@heroui/react";
|
||
import { CreditCard, Pencil } from "@gravity-ui/icons";
|
||
import { api, type IdTag, 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: "管理员" },
|
||
];
|
||
|
||
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
|
||
Accepted: "success",
|
||
Blocked: "danger",
|
||
Expired: "warning",
|
||
Invalid: "danger",
|
||
ConcurrentTx: "warning",
|
||
};
|
||
|
||
function generateIdTag(): string {
|
||
const chars = "0123456789ABCDEF";
|
||
let result = "";
|
||
for (let i = 0; i < 8; i++) {
|
||
result += chars[Math.floor(Math.random() * chars.length)];
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function fenToYuan(fen: number): string {
|
||
return (fen / 100).toFixed(2);
|
||
}
|
||
|
||
function FreeTagBinder({
|
||
freeTags,
|
||
selected,
|
||
onSelect,
|
||
onBind,
|
||
binding,
|
||
}: {
|
||
freeTags: IdTag[];
|
||
selected: string;
|
||
onSelect: (v: string) => void;
|
||
onBind: () => void;
|
||
binding: boolean;
|
||
}) {
|
||
const { contains } = useFilter({ sensitivity: "base" });
|
||
return (
|
||
<div className="flex items-end gap-2">
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label className="text-sm font-medium">从无主卡中绑定</Label>
|
||
<Autocomplete
|
||
fullWidth
|
||
placeholder={freeTags.length === 0 ? "暂无无主卡" : "搜索卡号"}
|
||
selectionMode="single"
|
||
isDisabled={freeTags.length === 0}
|
||
value={selected || null}
|
||
onChange={(key) => onSelect(key ? String(key) : "")}
|
||
>
|
||
<Autocomplete.Trigger>
|
||
<Autocomplete.Value>
|
||
{({ isPlaceholder, state }: any) => {
|
||
if (isPlaceholder || !state.selectedItems?.length)
|
||
return (
|
||
<span className="text-muted">
|
||
{freeTags.length === 0 ? "暂无无主卡" : "选择卡号"}
|
||
</span>
|
||
);
|
||
const tag = freeTags.find((t) => t.idTag === state.selectedItems[0]?.key);
|
||
return tag ? <span className="font-mono">{tag.idTag}</span> : null;
|
||
}}
|
||
</Autocomplete.Value>
|
||
<Autocomplete.ClearButton />
|
||
<Autocomplete.Indicator />
|
||
</Autocomplete.Trigger>
|
||
<Autocomplete.Popover>
|
||
<Autocomplete.Filter filter={contains}>
|
||
<SearchField autoFocus name="freeTagSearch" variant="secondary">
|
||
<SearchField.Group>
|
||
<SearchField.SearchIcon />
|
||
<SearchField.Input placeholder="搜索卡号" />
|
||
<SearchField.ClearButton />
|
||
</SearchField.Group>
|
||
</SearchField>
|
||
<ListBox renderEmptyState={() => <EmptyState>无匹配卡号</EmptyState>}>
|
||
{freeTags.map((t) => (
|
||
<ListBox.Item key={t.idTag} id={t.idTag} textValue={t.idTag}>
|
||
<span className="font-mono">{t.idTag}</span>
|
||
<span className="ml-2 text-xs text-muted">¥{(t.balance / 100).toFixed(2)}</span>
|
||
<ListBox.ItemIndicator />
|
||
</ListBox.Item>
|
||
))}
|
||
</ListBox>
|
||
</Autocomplete.Filter>
|
||
</Autocomplete.Popover>
|
||
</Autocomplete>
|
||
</div>
|
||
<Button isDisabled={binding || !selected} onPress={onBind}>
|
||
{binding ? <Spinner size="sm" /> : "绑定"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 [cardsUser, setCardsUser] = useState<UserRow | null>(null);
|
||
const [userTags, setUserTags] = useState<IdTag[]>([]);
|
||
const [tagsLoading, setTagsLoading] = useState(false);
|
||
const [newTagId, setNewTagId] = useState("");
|
||
const [creatingTag, setCreatingTag] = useState(false);
|
||
const [unlinkingTag, setUnlinkingTag] = useState<string | null>(null);
|
||
const [deletingTag, setDeletingTag] = useState<string | null>(null);
|
||
const [freeTags, setFreeTags] = useState<IdTag[]>([]);
|
||
const [selectedFreeTag, setSelectedFreeTag] = useState("");
|
||
const [bindingTag, setBindingTag] = 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 loadUserTags = async (userId: string) => {
|
||
setTagsLoading(true);
|
||
try {
|
||
const all = await api.idTags.list();
|
||
setUserTags(all.filter((t) => t.userId === userId));
|
||
setFreeTags(all.filter((t) => !t.userId));
|
||
} finally {
|
||
setTagsLoading(false);
|
||
}
|
||
};
|
||
|
||
const openCardsModal = (u: UserRow) => {
|
||
setCardsUser(u);
|
||
setSelectedFreeTag("");
|
||
setUserTags([]);
|
||
setFreeTags([]);
|
||
void loadUserTags(u.id);
|
||
};
|
||
|
||
const handleCreateTag = async () => {
|
||
if (!cardsUser || !newTagId) return;
|
||
setCreatingTag(true);
|
||
try {
|
||
await api.idTags.create({ idTag: newTagId, userId: cardsUser.id });
|
||
setNewTagId(generateIdTag());
|
||
await loadUserTags(cardsUser.id);
|
||
} finally {
|
||
setCreatingTag(false);
|
||
}
|
||
};
|
||
|
||
const handleUnlinkTag = async (idTag: string) => {
|
||
if (!cardsUser) return;
|
||
setUnlinkingTag(idTag);
|
||
try {
|
||
await api.idTags.update(idTag, { userId: null });
|
||
await loadUserTags(cardsUser.id);
|
||
} finally {
|
||
setUnlinkingTag(null);
|
||
}
|
||
};
|
||
|
||
const handleBindFreeTag = async () => {
|
||
if (!cardsUser || !selectedFreeTag) return;
|
||
setBindingTag(true);
|
||
try {
|
||
await api.idTags.update(selectedFreeTag, { userId: cardsUser.id });
|
||
setSelectedFreeTag("");
|
||
await loadUserTags(cardsUser.id);
|
||
} finally {
|
||
setBindingTag(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteTag = async (idTag: string) => {
|
||
if (!cardsUser) return;
|
||
setDeletingTag(idTag);
|
||
try {
|
||
await api.idTags.delete(idTag);
|
||
await loadUserTags(cardsUser.id);
|
||
} finally {
|
||
setDeletingTag(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
|
||
placeholder="创建账号的登录名"
|
||
value={createForm.username}
|
||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
|
||
/>
|
||
</TextField>
|
||
<TextField fullWidth>
|
||
<Label className="text-sm font-medium">密码</Label>
|
||
<Input
|
||
autoComplete="new-password"
|
||
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" ? "warning" : "success"}
|
||
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
|
||
placeholder="用户登录名"
|
||
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>
|
||
{/* Cards management */}
|
||
<Modal>
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="tertiary"
|
||
onPress={() => openCardsModal(u)}
|
||
>
|
||
<CreditCard className="size-4" />
|
||
</Button>
|
||
<Modal.Backdrop>
|
||
<Modal.Container scroll="outside">
|
||
<Modal.Dialog className="sm:max-w-2xl">
|
||
<Modal.CloseTrigger />
|
||
<Modal.Header>
|
||
<Modal.Heading>
|
||
{cardsUser
|
||
? `${cardsUser.name ?? cardsUser.username ?? cardsUser.email} 的储值卡`
|
||
: "储值卡管理"}
|
||
</Modal.Heading>
|
||
</Modal.Header>
|
||
<Modal.Body className="space-y-4">
|
||
<div className="flex items-end gap-2">
|
||
<TextField fullWidth>
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-sm font-medium">新建储值卡</Label>
|
||
<button
|
||
type="button"
|
||
className="text-xs text-accent hover:underline"
|
||
onClick={() => setNewTagId(generateIdTag())}
|
||
>
|
||
随机生成
|
||
</button>
|
||
</div>
|
||
<Input
|
||
className="font-mono"
|
||
placeholder="e.g. A1B2C3D4"
|
||
value={newTagId}
|
||
onChange={(e) => setNewTagId(e.target.value)}
|
||
/>
|
||
</TextField>
|
||
<Button
|
||
isDisabled={creatingTag || !newTagId}
|
||
onPress={handleCreateTag}
|
||
>
|
||
{creatingTag ? <Spinner size="sm" /> : "创建并绑定"}
|
||
</Button>
|
||
</div>
|
||
<FreeTagBinder
|
||
freeTags={freeTags}
|
||
selected={selectedFreeTag}
|
||
onSelect={setSelectedFreeTag}
|
||
onBind={handleBindFreeTag}
|
||
binding={bindingTag}
|
||
/>
|
||
{tagsLoading ? (
|
||
<div className="flex justify-center py-6">
|
||
<Spinner />
|
||
</div>
|
||
) : userTags.length === 0 ? (
|
||
<p className="py-4 text-center text-sm text-muted">
|
||
该用户暂无储值卡
|
||
</p>
|
||
) : (
|
||
<Table>
|
||
<Table.ScrollContainer>
|
||
<Table.Content aria-label="用户储值卡">
|
||
<Table.Header>
|
||
<Table.Column isRowHeader>卡号</Table.Column>
|
||
<Table.Column>状态</Table.Column>
|
||
<Table.Column>余额</Table.Column>
|
||
<Table.Column className="text-end">操作</Table.Column>
|
||
</Table.Header>
|
||
<Table.Body>
|
||
{userTags.map((tag) => (
|
||
<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>
|
||
<div className="flex justify-end gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant="tertiary"
|
||
isDisabled={
|
||
unlinkingTag === tag.idTag ||
|
||
deletingTag === tag.idTag
|
||
}
|
||
onPress={() => handleUnlinkTag(tag.idTag)}
|
||
>
|
||
{unlinkingTag === tag.idTag ? (
|
||
<Spinner size="sm" />
|
||
) : (
|
||
"解绑"
|
||
)}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger-soft"
|
||
isDisabled={
|
||
deletingTag === tag.idTag ||
|
||
unlinkingTag === tag.idTag
|
||
}
|
||
onPress={() => handleDeleteTag(tag.idTag)}
|
||
>
|
||
{deletingTag === tag.idTag ? (
|
||
<Spinner size="sm" />
|
||
) : (
|
||
"删除"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
))}
|
||
</Table.Body>
|
||
</Table.Content>
|
||
</Table.ScrollContainer>
|
||
</Table>
|
||
)}
|
||
</Modal.Body>
|
||
<Modal.Footer className="flex justify-end">
|
||
<Button slot="close" variant="ghost">
|
||
关闭
|
||
</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>
|
||
);
|
||
}
|