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;

View File

@@ -9,12 +9,21 @@ import {
getAdminTransferOrders,
getAdminWalletTransactions,
} from "@/api/admin-wallet";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
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,
@@ -138,6 +147,42 @@ const emptyTxnFilters: TxnFilters = {
abnormalOnly: false,
};
/** 下拉「不限」值;请求时转为空串不传参 */
const WALLET_FILTER_ALL = "__all__";
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
{ value: "transfer_in", label: "主站转入" },
{ value: "transfer_out", label: "主站转出" },
{ value: "transfer_out_refund", label: "转出失败回补" },
];
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "posted", label: "已记账" },
{ value: "pending_reconcile", label: "待对账" },
];
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "processing", label: "处理中" },
{ value: "success", label: "成功" },
{ value: "failed", label: "失败" },
{ value: "pending_reconcile", label: "待对账" },
];
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
function walletAdminSelectDisplayedLabel(
raw: unknown,
options: readonly { value: string; label: string }[],
): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === WALLET_FILTER_ALL) {
return "不限";
}
return options.find((o) => o.value === v)?.label ?? v;
}
export function WalletConsole(): React.ReactElement {
return (
<Tabs defaultValue="txns" className="w-full">
@@ -165,6 +210,7 @@ function TransferOrdersPanel(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
const [applied, setApplied] = useState<TransferFilters>(emptyTransferFilters);
@@ -176,7 +222,7 @@ function TransferOrdersPanel(): React.ReactElement {
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
const d = await getAdminTransferOrders({
page,
per_page: 20,
per_page: perPage,
abnormal: applied.abnormalOnly || undefined,
player_id:
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
@@ -196,7 +242,7 @@ function TransferOrdersPanel(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, applied]);
}, [page, perPage, applied]);
useEffect(() => {
void load();
@@ -207,6 +253,12 @@ function TransferOrdersPanel(): React.ReactElement {
setPage(1);
};
const resetFilters = () => {
setDraft(emptyTransferFilters);
setApplied(emptyTransferFilters);
setPage(1);
};
return (
<Card>
<CardHeader>
@@ -240,7 +292,7 @@ function TransferOrdersPanel(): React.ReactElement {
<Label htmlFor="to-account"></Label>
<Input
id="to-account"
placeholder="site_player_id / 用户名"
placeholder="主站玩家 ID 或用户名(模糊)"
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
@@ -255,32 +307,47 @@ function TransferOrdersPanel(): React.ReactElement {
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-from"></Label>
<Input
id="to-from"
type="date"
value={draft.createdFrom}
onChange={(e) => setDraft((d) => ({ ...d, createdFrom: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-to"></Label>
<Input
id="to-to"
type="date"
value={draft.createdTo}
onChange={(e) => setDraft((d) => ({ ...d, createdTo: e.target.value }))}
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="to-created-range"
label="请求日期范围"
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-status"></Label>
<Input
id="to-status"
placeholder="逗号分隔,如 pending_reconcile,failed"
value={draft.statusCsv}
onChange={(e) => setDraft((d) => ({ ...d, statusCsv: e.target.value }))}
/>
<Select
modal={false}
value={
draft.statusCsv === "" || !TRANSFER_ORDER_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
? WALLET_FILTER_ALL
: draft.statusCsv
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="to-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, TRANSFER_ORDER_STATUS_OPTIONS)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
{TRANSFER_ORDER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none"></span>
@@ -299,6 +366,9 @@ function TransferOrdersPanel(): React.ReactElement {
<Button type="button" size="sm" onClick={() => runSearch()}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
</Button>
@@ -373,32 +443,19 @@ function TransferOrdersPanel(): React.ReactElement {
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{data.total} · {data.page} /{" "}
{Math.max(1, Math.ceil(data.total / data.per_page))}
</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={loading || data.page >= Math.ceil(data.total / data.per_page)}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
<AdminListPaginationFooter
selectId="wallet-transfer-orders-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</>
) : null}
</CardContent>
@@ -412,6 +469,7 @@ function WalletTxnsPanel(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
@@ -423,7 +481,7 @@ function WalletTxnsPanel(): React.ReactElement {
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
const d = await getAdminWalletTransactions({
page,
per_page: 20,
per_page: perPage,
abnormal: applied.abnormalOnly || undefined,
player_id:
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
@@ -444,7 +502,7 @@ function WalletTxnsPanel(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, applied]);
}, [page, perPage, applied]);
useEffect(() => {
void load();
@@ -455,6 +513,12 @@ function WalletTxnsPanel(): React.ReactElement {
setPage(1);
};
const resetFilters = () => {
setDraft(emptyTxnFilters);
setApplied(emptyTxnFilters);
setPage(1);
};
return (
<Card>
<CardHeader>
@@ -488,7 +552,7 @@ function WalletTxnsPanel(): React.ReactElement {
<Label htmlFor="tx-account"></Label>
<Input
id="tx-account"
placeholder="site_player_id / 用户名"
placeholder="主站玩家 ID 或用户名(模糊)"
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
@@ -505,38 +569,75 @@ function WalletTxnsPanel(): React.ReactElement {
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-biz"></Label>
<Input
id="tx-biz"
placeholder="如 transfer_in"
value={draft.bizType}
onChange={(e) => setDraft((d) => ({ ...d, bizType: e.target.value }))}
/>
<Select
modal={false}
value={
draft.bizType === "" || !WALLET_TXN_BIZ_OPTIONS.some((o) => o.value === draft.bizType)
? WALLET_FILTER_ALL
: draft.bizType
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-biz" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-status"></Label>
<Input
id="tx-status"
placeholder="posted 或逗号分隔"
value={draft.statusCsv}
onChange={(e) => setDraft((d) => ({ ...d, statusCsv: e.target.value }))}
/>
<Select
modal={false}
value={
draft.statusCsv === "" || !WALLET_TXN_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
? WALLET_FILTER_ALL
: draft.statusCsv
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-from"></Label>
<Input
id="tx-from"
type="date"
value={draft.createdFrom}
onChange={(e) => setDraft((d) => ({ ...d, createdFrom: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-to"></Label>
<Input
id="tx-to"
type="date"
value={draft.createdTo}
onChange={(e) => setDraft((d) => ({ ...d, createdTo: e.target.value }))}
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="tx-created-range"
label="请求日期范围"
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
@@ -556,6 +657,9 @@ function WalletTxnsPanel(): React.ReactElement {
<Button type="button" size="sm" onClick={() => runSearch()}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
</Button>
@@ -630,32 +734,19 @@ function WalletTxnsPanel(): React.ReactElement {
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{data.total} · {data.page} /{" "}
{Math.max(1, Math.ceil(data.total / data.per_page))}
</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={loading || data.page >= Math.ceil(data.total / data.per_page)}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</div>
<AdminListPaginationFooter
selectId="wallet-transactions-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</>
) : null}
</CardContent>