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

40
src/api/admin-draws.ts Normal file
View File

@@ -0,0 +1,40 @@
import { adminRequest } from "@/lib/admin-http";
import { API_V1_PREFIX } from "./paths";
import type {
AdminDrawBatchesData,
AdminDrawListData,
AdminDrawPublishResponse,
AdminDrawShowData,
} from "@/types/api/admin-draws";
const A = `${API_V1_PREFIX}/admin`;
export type AdminDrawListQuery = {
page?: number;
per_page?: number;
draw_no?: string;
status?: string;
};
export async function getAdminDraws(q: AdminDrawListQuery = {}): Promise<AdminDrawListData> {
return adminRequest.get<AdminDrawListData>(`${A}/draws`, { params: q });
}
export async function getAdminDraw(drawId: number): Promise<AdminDrawShowData> {
return adminRequest.get<AdminDrawShowData>(`${A}/draws/${drawId}`);
}
export async function getAdminDrawResultBatches(drawId: number): Promise<AdminDrawBatchesData> {
return adminRequest.get<AdminDrawBatchesData>(`${A}/draws/${drawId}/result-batches`);
}
export async function postAdminPublishResultBatch(
drawId: number,
batchId: number,
): Promise<AdminDrawPublishResponse> {
return adminRequest.post<AdminDrawPublishResponse>(
`${A}/draws/${drawId}/result-batches/${batchId}/publish`,
);
}

View File

@@ -6,6 +6,12 @@ export {
getAdminTransferOrders,
getAdminWalletTransactions,
} from "@/api/admin-wallet";
export {
getAdminDraw,
getAdminDrawResultBatches,
getAdminDraws,
postAdminPublishResultBatch,
} from "@/api/admin-draws";
export type {
AdminAuthCaptchaResponse,
AdminAuthLoginRequest,

View File

@@ -0,0 +1,16 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { DrawSubnav } from "@/modules/draws/draw-subnav";
export default async function AdminDrawSegmentLayout(props: {
children: React.ReactNode;
params: Promise<{ drawId: string }>;
}) {
const { drawId } = await props.params;
return (
<ModuleScaffold className="w-full max-w-none">
<DrawSubnav drawId={drawId} />
{props.children}
</ModuleScaffold>
);
}

View File

@@ -0,0 +1,8 @@
import { DrawDetailConsole } from "@/modules/draws/draw-detail-console";
export default async function AdminDrawDetailPage(props: {
params: Promise<{ drawId: string }>;
}) {
const { drawId } = await props.params;
return <DrawDetailConsole drawId={drawId} />;
}

View File

@@ -0,0 +1,8 @@
import { DrawResultsConsole } from "@/modules/draws/draw-results-console";
export default async function AdminDrawResultsPage(props: {
params: Promise<{ drawId: string }>;
}) {
const { drawId } = await props.params;
return <DrawResultsConsole drawId={drawId} />;
}

View File

@@ -0,0 +1,8 @@
import { DrawPublishConsole } from "@/modules/draws/draw-publish-console";
export default async function AdminDrawPublishPage(props: {
params: Promise<{ drawId: string; batchId: string }>;
}) {
const { drawId, batchId } = await props.params;
return <DrawPublishConsole drawId={drawId} batchId={batchId} />;
}

View File

@@ -0,0 +1,8 @@
import { DrawReviewConsole } from "@/modules/draws/draw-review-console";
export default async function AdminDrawReviewPage(props: {
params: Promise<{ drawId: string }>;
}) {
const { drawId } = await props.params;
return <DrawReviewConsole drawId={drawId} />;
}

View File

@@ -1,5 +1,6 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { drawsModuleMeta } from "@/modules/draws/meta";
import { DrawsIndexConsole } from "@/modules/draws/draws-index-console";
import type { Metadata } from "next";
export const metadata: Metadata = {
@@ -8,14 +9,8 @@ export const metadata: Metadata = {
export default function AdminDrawsPage() {
return (
<ModuleScaffold>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
{" "}
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
src/modules/draws
</code>{" "}
</p>
<ModuleScaffold className="w-full max-w-none">
<DrawsIndexConsole />
</ModuleScaffold>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export function AdminDateField({
id,
label,
value,
onChange,
placeholder = "选择日期",
}: {
id: string;
label: string;
/** `yyyy-MM-dd` 或空 */
value: string;
onChange: (next: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = React.useState(false);
const parsed = React.useMemo(() => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return undefined;
}
const d = parse(value, "yyyy-MM-dd", new Date());
return Number.isNaN(d.getTime()) ? undefined : d;
}, [value]);
const summary = parsed ? format(parsed, "yyyy年M月d日", { locale: zhCN }) : placeholder;
return (
<div className="grid gap-1.5">
<Label htmlFor={id}>{label}</Label>
<Popover
modal={false}
open={open}
onOpenChange={(next) => {
setOpen(next);
}}
>
<PopoverTrigger
type="button"
id={id}
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm",
!parsed && "text-muted-foreground",
)}
>
<CalendarIcon className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">{summary}</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto min-w-fit p-0">
<Calendar
mode="single"
locale={zhCN}
captionLayout="dropdown"
selected={parsed}
defaultMonth={parsed}
onSelect={(d) => {
onChange(d ? format(d, "yyyy-MM-dd") : "");
setOpen(false);
}}
/>
<div className="flex justify-end border-t px-2 py-1">
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2"
onClick={() => {
onChange("");
setOpen(false);
}}
>
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import * as React from "react";
import type { DateRange } from "react-day-picker";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarRange } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
function parseYmd(value: string): Date | undefined {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return undefined;
}
const d = parse(value, "yyyy-MM-dd", new Date());
return Number.isNaN(d.getTime()) ? undefined : d;
}
function formatYmd(d: Date): string {
return format(d, "yyyy-MM-dd");
}
function summarize(from: string, to: string, placeholder: string): string {
const df = parseYmd(from);
const dt = parseYmd(to);
if (!df && !dt) {
return placeholder;
}
const a = df ? format(df, "yyyy年M月d日", { locale: zhCN }) : "…";
const b = dt ? format(dt, "yyyy年M月d日", { locale: zhCN }) : "…";
return `${a}${b}`;
}
/** shadcn Popover + Calendar `mode="range"`;输出与原先两个 `yyyy-MM-dd` 筛选字段兼容 */
export function AdminDateRangeField({
id,
label,
from: fromProp,
to: toProp,
onRangeChange,
placeholder = "选择日期范围",
}: {
id: string;
label?: string;
from: string;
to: string;
onRangeChange: (next: { from: string; to: string }) => void;
placeholder?: string;
}) {
const [open, setOpen] = React.useState(false);
const isMobile = useIsMobile();
const selected = React.useMemo((): DateRange | undefined => {
const df = parseYmd(fromProp);
const dt = parseYmd(toProp);
if (!df && !dt) {
return undefined;
}
if (df && !dt) {
return { from: df };
}
if (df && dt) {
return { from: df, to: dt };
}
return dt ? { from: dt } : undefined;
}, [fromProp, toProp]);
const hasSelection = Boolean(parseYmd(fromProp) || parseYmd(toProp));
const defaultMonth = selected?.from ?? selected?.to ?? new Date();
return (
<div className="grid gap-1.5">
{label ? (
<Label htmlFor={id} className="leading-none">
{label}
</Label>
) : null}
<Popover
modal={false}
open={open}
onOpenChange={(next) => {
setOpen(next);
}}
>
<PopoverTrigger
type="button"
id={id}
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"h-8 min-h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm",
!hasSelection && "text-muted-foreground",
)}
>
<CalendarRange className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">
{summarize(fromProp, toProp, placeholder)}
</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
</p>
<Calendar
mode="range"
locale={zhCN}
captionLayout="dropdown"
selected={selected}
defaultMonth={defaultMonth}
numberOfMonths={isMobile ? 1 : 2}
onSelect={(range) => {
if (!range?.from && !range?.to) {
onRangeChange({ from: "", to: "" });
return;
}
const nf = range.from ? formatYmd(range.from) : "";
const nt = range.to ? formatYmd(range.to) : "";
onRangeChange({ from: nf, to: nt });
}}
/>
<div className="flex items-center justify-end gap-2 border-t px-2 py-1.5">
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2"
onClick={() => {
onRangeChange({ from: "", to: "" });
setOpen(false);
}}
>
</Button>
<Button
type="button"
variant="secondary"
size="xs"
className="h-7 px-2"
onClick={() => {
setOpen(false);
}}
>
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
export const ADMIN_LIST_PER_PAGE_OPTIONS = [10, 20, 25, 50, 100] as const;
/** 服务端分页页码序列(省略号);与 shadcn Pagination 拼装用 */
export function adminListPageSlices(
current: number,
last: number,
delta = 1,
): (number | "ellipsis")[] {
if (last <= 1) {
return last === 1 ? [1] : [];
}
const cap = delta * 2 + 3;
if (last <= cap) {
return Array.from({ length: last }, (_, i) => i + 1);
}
const set = new Set<number>([1, last]);
for (let p = current - delta; p <= current + delta; p++) {
if (p >= 1 && p <= last) {
set.add(p);
}
}
const sorted = [...set].sort((a, b) => a - b);
const out: (number | "ellipsis")[] = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) {
out.push("ellipsis");
}
out.push(p);
prev = p;
}
return out;
}
export function AdminPerPagePicker({
selectId,
perPage,
onChange,
}: {
/** 表单控件 id · 区分同页多块列表 */
selectId: string;
perPage: number;
onChange: (next: number) => void;
}) {
return (
<div className="flex items-center gap-2">
<Label htmlFor={selectId} className="text-sm leading-none whitespace-nowrap">
</Label>
<Select
modal={false}
value={String(perPage)}
onValueChange={(v) => {
if (v == null || v === "") {
return;
}
onChange(Number(v));
}}
>
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
/** 表格底栏:左统计 + 右「每页条数」与 shadcn Pagination与 Data Table pagination 一节一致) */
export function AdminListPaginationFooter({
selectId,
total,
page,
lastPage,
perPage,
loading,
onPerPageChange,
onPageChange,
}: {
selectId: string;
total: number;
page: number;
lastPage: number;
perPage: number;
loading: boolean;
onPerPageChange: (nextPerPage: number) => void;
onPageChange: (nextPage: number) => void;
}) {
return (
<div className="flex flex-col gap-4 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm tabular-nums">
{total} {page} / {lastPage}
</p>
<div className="flex w-full flex-col items-stretch gap-4 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end lg:gap-6">
<AdminPerPagePicker
selectId={selectId}
perPage={perPage}
onChange={(next) => {
onPerPageChange(next);
}}
/>
{lastPage > 1 ? (
<Pagination className="mx-0 flex w-full max-w-none justify-center sm:w-auto sm:justify-end md:justify-end">
<PaginationContent className="flex-wrap justify-center md:justify-end">
<PaginationItem>
<PaginationPrevious
href="#"
text="上一页"
aria-disabled={page <= 1 || loading}
className={cn(
(page <= 1 || loading) && "pointer-events-none opacity-50",
)}
onClick={(e) => {
e.preventDefault();
if (page <= 1 || loading) {
return;
}
onPageChange(Math.max(1, page - 1));
}}
/>
</PaginationItem>
{adminListPageSlices(page, lastPage).map((item, idx) =>
item === "ellipsis" ? (
<PaginationItem key={`${selectId}-e-${idx}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={`${selectId}-${item}`}>
<PaginationLink
href="#"
size="sm"
isActive={item === page}
className="min-w-8 tabular-nums"
aria-disabled={loading}
onClick={(e) => {
e.preventDefault();
if (loading) {
return;
}
onPageChange(item);
}}
>
{item}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href="#"
text="下一页"
aria-disabled={page >= lastPage || loading}
className={cn(
(page >= lastPage || loading) && "pointer-events-none opacity-50",
)}
onClick={(e) => {
e.preventDefault();
if (page >= lastPage || loading) {
return;
}
onPageChange(Math.min(lastPage, page + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,130 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -38,6 +38,7 @@ function SelectTrigger({
}) {
return (
<SelectPrimitive.Trigger
type="button"
data-slot="select-trigger"
data-size={size}
className={cn(

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>

View File

@@ -0,0 +1,89 @@
export type AdminDrawListItem = {
id: number;
draw_no: string;
business_date: string;
sequence_no: number;
status: string;
start_time: string | null;
close_time: string | null;
draw_time: string | null;
cooling_end_time: string | null;
result_source: string | null;
current_result_version: number;
settle_version: number;
is_reopened: boolean;
updated_at: string | null;
};
export type AdminDrawListMeta = {
current_page: number;
per_page: number;
total: number;
last_page: number;
};
export type AdminDrawListData = {
items: AdminDrawListItem[];
meta: AdminDrawListMeta;
};
export type AdminDrawShowData = {
id: number;
draw_no: string;
business_date: string;
sequence_no: number;
status: string;
hall_preview_status: string;
start_time: string | null;
close_time: string | null;
draw_time: string | null;
cooling_end_time: string | null;
result_source: string | null;
current_result_version: number;
settle_version: number;
is_reopened: boolean;
created_at: string | null;
updated_at: string | null;
result_batch_counts: {
total: number;
pending_review: number;
published: number;
};
};
export type AdminDrawBatchItemRow = {
prize_type: string;
prize_index: number;
number_4d: string;
suffix_3d: string | null;
suffix_2d: string | null;
head_digit: number | null;
tail_digit: number | null;
};
export type AdminDrawBatchRow = {
id: number;
result_version: number;
source_type: string;
rng_seed_hash: string | null;
status: string;
created_by: number | null;
confirmed_by: number | null;
confirmed_at: string | null;
created_at: string | null;
updated_at: string | null;
items: AdminDrawBatchItemRow[];
};
export type AdminDrawBatchesData = {
draw_id: number;
draw_no: string;
draw_status: string;
batches: AdminDrawBatchRow[];
};
export type AdminDrawPublishResponse = {
draw_no: string;
status: string;
result_version: number;
};

View File

@@ -5,6 +5,15 @@ export type {
AdminProfile,
} from "./admin-auth";
export type { AdminPingResponse } from "./admin-ping";
export type {
AdminDrawBatchItemRow,
AdminDrawBatchRow,
AdminDrawBatchesData,
AdminDrawListData,
AdminDrawListItem,
AdminDrawPublishResponse,
AdminDrawShowData,
} from "./admin-draws";
export type {
AdminPlayerWalletsData,
AdminPlayerWalletRow,