feat: 添加日期处理库和日历选择器,更新管理员抽奖模块

This commit is contained in:
2026-05-09 17:40:35 +08:00
parent f19cdb48ad
commit ac3f28459b
28 changed files with 2186 additions and 117 deletions

View File

@@ -0,0 +1,116 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getAdminDraw } from "@/api/admin-draws";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { DrawStatusBadge } from "./draw-status-badge";
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid gap-1 sm:grid-cols-[10rem_1fr] sm:items-start">
<Label className="text-muted-foreground">{label}</Label>
<div className="text-sm">{children}</div>
</div>
);
}
export function DrawDetailConsole({ drawId }: { drawId: string }) {
const idNum = Number(drawId);
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminDrawShowData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setData(await getAdminDraw(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
} finally {
setLoading(false);
}
}, [idNum]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
<code className="rounded bg-muted px-1 py-0.5 text-xs">status</code>{" "}
tick DB
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-base font-semibold">{data.draw_no}</span>
<DrawStatusBadge status={data.status} label={`DB · ${data.status}`} />
<DrawStatusBadge
status={data.hall_preview_status}
label={`大厅预览 · ${data.hall_preview_status}`}
/>
</div>
<Separator />
<div className="grid gap-4">
<Field label="业务日">{data.business_date}</Field>
<Field label="流水序号">{data.sequence_no}</Field>
<Field label="开始时间">{formatDt(data.start_time)}</Field>
<Field label="封盘时间">{formatDt(data.close_time)}</Field>
<Field label="计划开奖">{formatDt(data.draw_time)}</Field>
<Field label="冷静期结束">{formatDt(data.cooling_end_time)}</Field>
<Field label="结果来源">{data.result_source ?? "—"}</Field>
<Field label="当前结果版本">{data.current_result_version}</Field>
<Field label="结算版本">{data.settle_version}</Field>
<Field label="是否重开">{data.is_reopened ? "是" : "否"}</Field>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> / </CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-6 text-sm">
<span>{data.result_batch_counts.total}</span>
<span className="text-amber-600 dark:text-amber-400">
{data.result_batch_counts.pending_review}
</span>
<span className="text-emerald-600 dark:text-emerald-400">
{data.result_batch_counts.published}
</span>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { getAdminDrawResultBatches, postAdminPublishResultBatch } from "@/api/admin-draws";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
const idNum = Number(drawId);
const batchNum = Number(batchId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [publishing, setPublishing] = useState(false);
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
} finally {
setLoading(false);
}
}, [idNum]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
if (!Number.isFinite(batchNum)) return undefined;
return data?.batches.find((b) => b.id === batchNum);
}, [batchNum, data]);
async function publish(): Promise<void> {
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
setPublishing(true);
try {
const res = await postAdminPublishResultBatch(idNum, batchNum);
toast.success(`已发布 · ${res.draw_no} · 状态 ${res.status}`);
await load();
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "发布失败";
toast.error(msg);
} finally {
setPublishing(false);
}
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
}
if (!batch) {
return (
<Alert variant="destructive">
<AlertTitle></AlertTitle>
<AlertDescription> batch id</AlertDescription>
</Alert>
);
}
const canPublish = batch.status === "pending_review";
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Link href={`/admin/draws/${drawId}/review`} className={buttonVariants({ variant: "ghost", size: "sm" })}>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
<span className="font-mono font-medium">{data.draw_no}</span> · v
{batch.result_version}{" "}
<span className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{batch.status}</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!canPublish ? (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription>
pending_review {batch.status}
</AlertDescription>
</Alert>
) : (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
</Alert>
)}
<div className="overflow-x-auto rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>#</TableHead>
<TableHead className="font-mono">4D</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batch.items.map((it) => (
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
<TableCell className="text-xs">{it.prize_type}</TableCell>
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell>
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className="font-mono text-xs text-muted-foreground">
RNG {batch.rng_seed_hash ?? "—"}
</p>
</CardContent>
<CardFooter className="justify-end gap-2">
<Link
href={`/admin/draws/${drawId}/results`}
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
>
</Link>
<Button
type="button"
disabled={!canPublish || publishing}
onClick={() => void publish()}
>
{publishing ? "提交中…" : "确认发布"}
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawResultsConsole({ drawId }: { drawId: string }) {
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
} finally {
setLoading(false);
}
}, [idNum]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
}
const published = data.batches.filter((b) => b.status === "published");
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">
{data.draw_no} · <DrawStatusBadge status={data.draw_status} /> ·
/
</p>
</div>
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
</Link>
</div>
{published.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
RNG
</CardContent>
</Card>
) : (
published.map((batch) => <BatchTable key={batch.id} batch={batch} />)
)}
</div>
);
}
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base"> v{batch.result_version}</CardTitle>
<CardDescription className="font-mono text-xs">
RNG {batch.rng_seed_hash ?? "—"} · {batch.confirmed_at ?? "—"}
</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>#</TableHead>
<TableHead className="font-mono">4D</TableHead>
<TableHead className="hidden sm:table-cell">3</TableHead>
<TableHead className="hidden sm:table-cell">2</TableHead>
<TableHead className="hidden md:table-cell">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batch.items.map((it) => (
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
<TableCell className="text-xs">{it.prize_type}</TableCell>
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell>
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell>
<TableCell className="hidden font-mono text-xs sm:table-cell">
{it.suffix_3d ?? "—"}
</TableCell>
<TableCell className="hidden font-mono text-xs sm:table-cell">
{it.suffix_2d ?? "—"}
</TableCell>
<TableCell className="hidden text-xs md:table-cell">
{it.head_digit ?? "—"} / {it.tail_digit ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawReviewConsole({ drawId }: { drawId: string }) {
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
} finally {
setLoading(false);
}
}, [idNum]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
data,
]);
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
RNG 23
DB <DrawStatusBadge status={data.draw_status} />
</CardDescription>
</CardHeader>
<CardContent>
{pending.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
pending_review
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/draws/${drawId}/review/${b.id}`}
className={cn(buttonVariants({ size: "sm" }))}
>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,23 @@
import { Badge } from "@/components/ui/badge";
const emphasis: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
open: "default",
closing: "destructive",
closed: "secondary",
drawing: "secondary",
review: "outline",
cooldown: "secondary",
pending: "outline",
};
export function DrawStatusBadge({
status,
label,
}: {
status: string;
/** 可与 DB 不同时展示预览态文案 */
label?: string;
}) {
const v = emphasis[status] ?? "outline";
return <Badge variant={v}>{label ?? status}</Badge>;
}

View File

@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const segments = [
{ suffix: "", key: "status", label: "当前状态" },
{ suffix: "/results", key: "results", label: "开奖结果" },
{ suffix: "/review", key: "review", label: "审核 / 发布" },
] as const;
export function DrawSubnav({ drawId }: { drawId: string }) {
const pathname = usePathname();
const base = `/admin/draws/${drawId}`;
return (
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3">
{segments.map(({ suffix, key, label }) => {
const href = `${base}${suffix}`;
const active =
suffix === ""
? pathname === base || pathname === `${base}/`
: suffix === "/review"
? pathname === href || pathname?.startsWith(`${href}/`)
: pathname === href;
return (
<Link
key={key}
href={href}
className={cn(
buttonVariants({ variant: active ? "default" : "outline", size: "sm" }),
)}
>
{label}
</Link>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,262 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { getAdminDraws } from "@/api/admin-draws";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
import { DrawStatusBadge } from "./draw-status-badge";
/** 下拉「不限」;请求时不传 status */
const DRAW_FILTER_ALL = "__all__";
/** 与 {@see App\Lottery\DrawStatus} 一致 */
const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "pending", label: "未开始" },
{ value: "open", label: "可下注" },
{ value: "closing", label: "封盘中" },
{ value: "closed", label: "已封盘待开奖" },
{ value: "drawing", label: "开奖处理中" },
{ value: "review", label: "待人工审核" },
{ value: "cooldown", label: "冷静期" },
{ value: "settling", label: "结算处理中" },
{ value: "settled", label: "已结算" },
{ value: "cancelled", label: "已取消" },
];
function drawAdminStatusSelectLabel(raw: unknown): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === DRAW_FILTER_ALL) {
return "不限";
}
return DRAW_STATUS_OPTIONS.find((o) => o.value === v)?.label ?? v;
}
export function DrawsIndexConsole() {
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminDrawListData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draftDrawNo, setDraftDrawNo] = useState("");
const [draftStatus, setDraftStatus] = useState("");
const [appliedDrawNo, setAppliedDrawNo] = useState("");
const [appliedStatus, setAppliedStatus] = useState("");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<number>(20);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const d = await getAdminDraws({
page,
per_page: perPage,
draw_no: appliedDrawNo.trim() || undefined,
status:
appliedStatus.trim() === "" || appliedStatus === DRAW_FILTER_ALL
? undefined
: appliedStatus.trim(),
});
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载失败,请检查登录与 API 配置";
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, appliedDrawNo, appliedStatus]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Grid桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}
<div
className="
grid max-w-full gap-x-6 gap-y-3
[grid-template-areas:'dl'_'di'_'sl'_'si'_'act']
sm:grid-cols-[minmax(0,12rem)_minmax(0,11rem)_auto]
sm:gap-y-1.5
sm:[grid-template-areas:'dl_sl_ah'_'di_si_act']
"
>
<Label htmlFor="draw-filter-no" className="[grid-area:dl]">
</Label>
<Input
id="draw-filter-no"
placeholder="模糊匹配期号"
value={draftDrawNo}
className="[grid-area:di] w-full min-w-0 sm:w-full"
onChange={(e) => setDraftDrawNo(e.target.value)}
/>
<Label htmlFor="draw-filter-status" className="[grid-area:sl]">
</Label>
<div className="[grid-area:si] min-w-0">
<Select
modal={false}
value={
draftStatus === "" ||
!DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
? DRAW_FILTER_ALL
: draftStatus
}
onValueChange={(v) =>
setDraftStatus(v == null || v === DRAW_FILTER_ALL ? "" : String(v))
}
>
<SelectTrigger id="draw-filter-status" className="h-8 w-full min-w-0 sm:w-44">
<SelectValue>{(v) => drawAdminStatusSelectLabel(v)}</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={DRAW_FILTER_ALL}></SelectItem>
{DRAW_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span
className="[grid-area:ah] hidden select-none text-sm font-medium leading-none sm:block"
aria-hidden
>
{/* 占位:与 Label 行同高,使按钮与输入控件同行对齐 */}
&nbsp;
</span>
<div className="[grid-area:act] flex flex-wrap gap-2">
<Button
type="button"
onClick={() => {
setAppliedDrawNo(draftDrawNo);
setAppliedStatus(draftStatus);
setPage(1);
}}
>
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setDraftDrawNo("");
setDraftStatus("");
setAppliedDrawNo("");
setAppliedStatus("");
setPage(1);
}}
>
</Button>
</div>
</div>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
<div className="rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
</TableCell>
</TableRow>
) : data === null || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.items.map((row: AdminDrawListItem) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.draw_no}</TableCell>
<TableCell>
<DrawStatusBadge status={row.status} />
</TableCell>
<TableCell className="text-sm">{formatDt(row.draw_time)}</TableCell>
<TableCell className="text-sm">{formatDt(row.close_time)}</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/draws/${row.id}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
</Link>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data ? (
<AdminListPaginationFooter
selectId="draw-list-per-page"
total={data.meta.total}
page={page}
lastPage={data.meta.last_page}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -1,5 +1,5 @@
export const drawsModuleMeta = {
segment: "draws",
title: "开奖",
description: "期号、开奖结果与时间线(占位)。",
description: "期号列表、状态、开奖结果、审核与发布。",
} as const;