Files
helios-evcs/apps/web/app/dashboard/users/page.tsx
Timothy Yin 02a361488b feat(api): add stats chart endpoint for admin access with time series data
feat(dayjs): integrate dayjs for date handling and formatting across the application
refactor(routes): update date handling in id-tags, transactions, users, and dashboard routes to use dayjs
style(globals): improve CSS variable definitions for better readability and consistency
deps: add dayjs as a dependency for date manipulation
2026-03-11 21:34:21 +08:00

694 lines
28 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 {
Autocomplete,
Button,
Chip,
EmptyState,
Input,
Label,
ListBox,
Modal,
SearchField,
Select,
Spinner,
Table,
TextField,
useFilter,
} from "@heroui/react";
import { CreditCard, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
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 [refreshing, setRefreshing] = useState(false);
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);
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
setUsers(await api.users.list());
} catch {
// ignore
} finally {
setRefreshing(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>
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={handleRefresh} aria-label="刷新">
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<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>
</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">
{dayjs(u.createdAt).format("YYYY/M/D HH:mm:ss")}
</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>
);
}