feat: add grid view for IdTagsPage and toggle button

This commit is contained in:
2026-03-12 13:30:02 +08:00
parent 9f92b57371
commit d1bff8bfd9

View File

@@ -24,9 +24,11 @@ import {
} from "@heroui/react"; } from "@heroui/react";
import { parseDate } from "@internationalized/date"; import { parseDate } from "@internationalized/date";
import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons"; import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { LayoutGrid, List } from "lucide-react";
import { api, type IdTag, type UserRow } from "@/lib/api"; import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs"; import dayjs from "@/lib/dayjs";
import { IdTagCard } from "@/components/id-tag-card";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = { const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Accepted: "success", Accepted: "success",
@@ -396,6 +398,7 @@ export default function IdTagsPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingTag, setDeletingTag] = useState<string | null>(null); const [deletingTag, setDeletingTag] = useState<string | null>(null);
const [claiming, setClaiming] = useState(false); const [claiming, setClaiming] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const { const {
data: idTagsData, data: idTagsData,
@@ -505,6 +508,19 @@ export default function IdTagsPage() {
> >
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} /> <ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button> </Button>
<Button
isIconOnly
size="sm"
variant="ghost"
onPress={() => setViewMode(viewMode === "table" ? "grid" : "table")}
aria-label={viewMode === "table" ? "切换到卡片视图" : "切换到列表视图"}
>
{viewMode === "table" ? (
<LayoutGrid className="size-4" />
) : (
<List className="size-4" />
)}
</Button>
<Modal> <Modal>
<Button size="sm" variant="secondary" onPress={openCreate}> <Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" /> <Plus className="size-4" />
@@ -547,69 +563,46 @@ export default function IdTagsPage() {
)} )}
</div> </div>
<Table> {/* ── Grid view ─────────────────────────────────────────── */}
<Table.ScrollContainer> {(!isAdmin || viewMode === "grid") && (
<Table.Content aria-label="储值卡列表" className="min-w-200"> <>
<Table.Header> {loading ? (
<Table.Column isRowHeader></Table.Column> <div className="flex justify-center py-16">
<Table.Column></Table.Column> <Spinner />
<Table.Column></Table.Column> </div>
{isAdmin && <Table.Column></Table.Column>} ) : tags.length === 0 ? (
<Table.Column></Table.Column> <div className="rounded-2xl border border-border px-6 py-14 text-center text-sm text-muted">
<Table.Column></Table.Column>
<Table.Column></Table.Column> </div>
{isAdmin && <Table.Column className="text-end"></Table.Column>} ) : (
</Table.Header> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无储值卡"}
</div>
)}
>
{tags.map((tag) => { {tags.map((tag) => {
const owner = users.find((u) => u.id === tag.userId); const owner = users.find((u) => u.id === tag.userId);
return ( return (
<Table.Row key={tag.idTag} id={tag.idTag}> <div key={tag.idTag} className="space-y-2">
<Table.Cell className="font-mono font-medium">{tag.idTag}</Table.Cell> <IdTagCard
<Table.Cell> idTag={tag.idTag}
<Chip balance={tag.balance}
color={statusColorMap[tag.status] ?? "warning"} layout={tag.cardLayout ?? undefined}
size="sm" skin={tag.cardSkin ?? undefined}
variant="soft" />
>
{tag.status}
</Chip>
</Table.Cell>
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
{isAdmin && ( {isAdmin && (
<Table.Cell className="text-sm"> <div className="flex items-center justify-between px-1">
{owner ? ( <div className="flex min-w-0 items-center gap-1.5">
<span title={owner.email}> <Chip
{owner.name ?? owner.username ?? owner.email} color={statusColorMap[tag.status] ?? "warning"}
</span> size="sm"
) : ( variant="soft"
<span className="text-muted"></span> >
)} {tag.status}
</Table.Cell> </Chip>
)} {owner && (
<Table.Cell> <span className="truncate text-xs text-muted">
{tag.expiryDate ? ( {owner.name ?? owner.username ?? owner.email}
dayjs(tag.expiryDate).format("YYYY/M/D") </span>
) : ( )}
<span className="text-muted"></span> </div>
)} <div className="flex shrink-0 gap-1">
</Table.Cell>
<Table.Cell className="font-mono">
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
{isAdmin && (
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
<Modal> <Modal>
<Button <Button
isIconOnly isIconOnly
@@ -647,7 +640,6 @@ export default function IdTagsPage() {
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
{/* Delete button */}
<Modal> <Modal>
<Button <Button
isDisabled={deletingTag === tag.idTag} isDisabled={deletingTag === tag.idTag}
@@ -699,15 +691,180 @@ export default function IdTagsPage() {
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
</div> </div>
</Table.Cell> </div>
)} )}
</Table.Row> </div>
); );
})} })}
</Table.Body> </div>
</Table.Content> )}
</Table.ScrollContainer> </>
</Table> )}
{/* ── Table view (admin only) ────────────────────────────────── */}
{isAdmin && viewMode === "table" && (
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="储值卡列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
{isAdmin && <Table.Column></Table.Column>}
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
{isAdmin && <Table.Column className="text-end"></Table.Column>}
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无储值卡"}
</div>
)}
>
{tags.map((tag) => {
const owner = users.find((u) => u.id === tag.userId);
return (
<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>
{isAdmin && (
<Table.Cell className="text-sm">
{owner ? (
<span title={owner.email}>
{owner.name ?? owner.username ?? owner.email}
</span>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
)}
<Table.Cell>
{tag.expiryDate ? (
dayjs(tag.expiryDate).format("YYYY/M/D")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell className="font-mono">
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
{isAdmin && (
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
<Modal>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => openEdit(tag)}
>
<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">
<TagFormBody
form={form}
setForm={setForm}
isEdit={true}
users={users}
tags={tags}
/>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Delete button */}
<Modal>
<Button
isDisabled={deletingTag === tag.idTag}
isIconOnly
size="sm"
variant="danger-soft"
>
{deletingTag === tag.idTag ? (
<Spinner size="sm" />
) : (
<TrashBin className="size-4" />
)}
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
{tag.idTag}
</span>
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deletingTag === tag.idTag}
onPress={() => handleDelete(tag.idTag)}
>
{deletingTag === tag.idTag ? (
<Spinner size="sm" />
) : (
"确认删除"
)}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
)}
</Table.Row>
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
)}
</div> </div>
); );
} }