feat(users): add card management functionality with tag creation and binding

This commit is contained in:
2026-03-10 22:03:40 +08:00
parent a349286049
commit 476b48addb

View File

@@ -2,19 +2,23 @@
import { useEffect, useState } from "react";
import {
Autocomplete,
Button,
Chip,
EmptyState,
Input,
Label,
ListBox,
Modal,
SearchField,
Select,
Spinner,
Table,
TextField,
useFilter,
} from "@heroui/react";
import { Pencil } from "@gravity-ui/icons";
import { api, type UserRow } from "@/lib/api";
import { CreditCard, Pencil } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
type CreateForm = {
@@ -38,6 +42,98 @@ const ROLE_OPTIONS = [
{ 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;
@@ -50,6 +146,16 @@ export default function UsersPage() {
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);
@@ -116,6 +222,71 @@ export default function UsersPage() {
}
};
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 (
@@ -340,6 +511,148 @@ export default function UsersPage() {
</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}