diff --git a/package-lock.json b/package-lock.json index a618fa9..d99649b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,12 @@ "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^1.14.0", "next": "16.2.6", "next-themes": "^0.4.6", "react": "19.2.4", + "react-day-picker": "^10.0.0", "react-dom": "19.2.4", "shadcn": "^4.7.0", "sonner": "^2.0.7", @@ -522,6 +524,12 @@ } } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.65.0", "resolved": "https://registry.npmmirror.com/@dotenvx/dotenvx/-/dotenvx-1.65.0.tgz", @@ -4076,6 +4084,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", @@ -8161,6 +8179,26 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-10.0.0.tgz", + "integrity": "sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", diff --git a/package.json b/package.json index 28234b0..a23e8db 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,12 @@ "axios": "^1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^1.14.0", "next": "16.2.6", "next-themes": "^0.4.6", "react": "19.2.4", + "react-day-picker": "^10.0.0", "react-dom": "19.2.4", "shadcn": "^4.7.0", "sonner": "^2.0.7", diff --git a/src/api/admin-draws.ts b/src/api/admin-draws.ts new file mode 100644 index 0000000..5030de2 --- /dev/null +++ b/src/api/admin-draws.ts @@ -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 { + return adminRequest.get(`${A}/draws`, { params: q }); +} + +export async function getAdminDraw(drawId: number): Promise { + return adminRequest.get(`${A}/draws/${drawId}`); +} + +export async function getAdminDrawResultBatches(drawId: number): Promise { + return adminRequest.get(`${A}/draws/${drawId}/result-batches`); +} + +export async function postAdminPublishResultBatch( + drawId: number, + batchId: number, +): Promise { + return adminRequest.post( + `${A}/draws/${drawId}/result-batches/${batchId}/publish`, + ); +} diff --git a/src/api/index.ts b/src/api/index.ts index 6614b09..e51a7f3 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,6 +6,12 @@ export { getAdminTransferOrders, getAdminWalletTransactions, } from "@/api/admin-wallet"; +export { + getAdminDraw, + getAdminDrawResultBatches, + getAdminDraws, + postAdminPublishResultBatch, +} from "@/api/admin-draws"; export type { AdminAuthCaptchaResponse, AdminAuthLoginRequest, diff --git a/src/app/admin/(shell)/draws/[drawId]/layout.tsx b/src/app/admin/(shell)/draws/[drawId]/layout.tsx new file mode 100644 index 0000000..949ea0a --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/layout.tsx @@ -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 ( + + + {props.children} + + ); +} diff --git a/src/app/admin/(shell)/draws/[drawId]/page.tsx b/src/app/admin/(shell)/draws/[drawId]/page.tsx new file mode 100644 index 0000000..e1b646c --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/page.tsx @@ -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 ; +} diff --git a/src/app/admin/(shell)/draws/[drawId]/results/page.tsx b/src/app/admin/(shell)/draws/[drawId]/results/page.tsx new file mode 100644 index 0000000..5e3b75a --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/results/page.tsx @@ -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 ; +} diff --git a/src/app/admin/(shell)/draws/[drawId]/review/[batchId]/page.tsx b/src/app/admin/(shell)/draws/[drawId]/review/[batchId]/page.tsx new file mode 100644 index 0000000..6921728 --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/review/[batchId]/page.tsx @@ -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 ; +} diff --git a/src/app/admin/(shell)/draws/[drawId]/review/page.tsx b/src/app/admin/(shell)/draws/[drawId]/review/page.tsx new file mode 100644 index 0000000..5d69050 --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/review/page.tsx @@ -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 ; +} diff --git a/src/app/admin/(shell)/draws/page.tsx b/src/app/admin/(shell)/draws/page.tsx index 557567e..82866c0 100644 --- a/src/app/admin/(shell)/draws/page.tsx +++ b/src/app/admin/(shell)/draws/page.tsx @@ -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 ( - -

- 业务组件请放在{" "} - - src/modules/draws - {" "} - 下。 -

+ + ); } diff --git a/src/components/admin/admin-date-field.tsx b/src/components/admin/admin-date-field.tsx new file mode 100644 index 0000000..66a7538 --- /dev/null +++ b/src/components/admin/admin-date-field.tsx @@ -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 ( +
+ + { + setOpen(next); + }} + > + + + {summary} + + + { + onChange(d ? format(d, "yyyy-MM-dd") : ""); + setOpen(false); + }} + /> +
+ +
+
+
+
+ ); +} diff --git a/src/components/admin/admin-date-range-field.tsx b/src/components/admin/admin-date-range-field.tsx new file mode 100644 index 0000000..76a8a0d --- /dev/null +++ b/src/components/admin/admin-date-range-field.tsx @@ -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 ( +
+ {label ? ( + + ) : null} + { + setOpen(next); + }} + > + + + + {summarize(fromProp, toProp, placeholder)} + + + +

+ 先选开始日,再选结束日(单日可对同一天点两次);点「完成」关闭面板。 +

+ { + 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 }); + }} + /> +
+ + +
+
+
+
+ ); +} diff --git a/src/components/admin/admin-list-pagination-footer.tsx b/src/components/admin/admin-list-pagination-footer.tsx new file mode 100644 index 0000000..208a78b --- /dev/null +++ b/src/components/admin/admin-list-pagination-footer.tsx @@ -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([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 ( +
+ + +
+ ); +} + +/** 表格底栏:左统计 + 右「每页条数」与 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 ( +
+

+ 共 {total} 条;第 {page} / {lastPage} 页 +

+
+ { + onPerPageChange(next); + }} + /> + {lastPage > 1 ? ( + + + + { + e.preventDefault(); + if (page <= 1 || loading) { + return; + } + onPageChange(Math.max(1, page - 1)); + }} + /> + + {adminListPageSlices(page, lastPage).map((item, idx) => + item === "ellipsis" ? ( + + + + ) : ( + + { + e.preventDefault(); + if (loading) { + return; + } + onPageChange(item); + }} + > + {item} + + + ), + )} + + = 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)); + }} + /> + + + + ) : null} +
+
+ ); +} diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..76bc503 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -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 & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + 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 ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + +
+ ); +} diff --git a/src/modules/draws/draw-results-console.tsx b/src/modules/draws/draw-results-console.tsx new file mode 100644 index 0000000..b19a11d --- /dev/null +++ b/src/modules/draws/draw-results-console.tsx @@ -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(null); + const [error, setError] = useState(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

加载中…

; + } + + if (error || !data) { + return

{error ?? "无数据"}

; + } + + const published = data.batches.filter((b) => b.status === "published"); + + return ( +
+
+
+

开奖结果

+

+ 期号 {data.draw_no} · 当期库内状态 · + 以下为已发布批次号码表;未发布请在「审核 / 发布」中处理。 +

+
+ + 去审核 + +
+ + {published.length === 0 ? ( + + + 暂无「已发布」批次。若存在待审核 RNG 结果,请先完成审核发布。 + + + ) : ( + published.map((batch) => ) + )} +
+ ); +} + +function BatchTable({ batch }: { batch: AdminDrawBatchRow }) { + return ( + + + 版本 v{batch.result_version} + + RNG 摘要 {batch.rng_seed_hash ?? "—"} · 确认时间 {batch.confirmed_at ?? "—"} + + + + + + + 奖项 + # + 4D + 尾3 + 尾2 + 头/尾 + + + + {batch.items.map((it) => ( + + {it.prize_type} + {it.prize_index} + {it.number_4d} + + {it.suffix_3d ?? "—"} + + + {it.suffix_2d ?? "—"} + + + {it.head_digit ?? "—"} / {it.tail_digit ?? "—"} + + + ))} + +
+
+
+ ); +} diff --git a/src/modules/draws/draw-review-console.tsx b/src/modules/draws/draw-review-console.tsx new file mode 100644 index 0000000..f09373e --- /dev/null +++ b/src/modules/draws/draw-review-console.tsx @@ -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(null); + const [error, setError] = useState(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

加载中…

; + } + + if (error || !data) { + return

{error ?? "无数据"}

; + } + + return ( + + + 审核 + + 待审核 RNG 批次会出现在下表;点「审核与发布」进入发布页核对 23 组号码后提交。 + 当前 DB 状态:。 + + + + {pending.length === 0 ? ( +

+ 当前没有待审核(pending_review)批次。 +

+ ) : ( + + + + 批次 ID + 版本 + 号码条数 + 操作 + + + + {pending.map((b) => ( + + {b.id} + v{b.result_version} + {b.items.length} + + + 审核与发布 + + + + ))} + +
+ )} +
+
+ ); +} diff --git a/src/modules/draws/draw-status-badge.tsx b/src/modules/draws/draw-status-badge.tsx new file mode 100644 index 0000000..29c5504 --- /dev/null +++ b/src/modules/draws/draw-status-badge.tsx @@ -0,0 +1,23 @@ +import { Badge } from "@/components/ui/badge"; + +const emphasis: Record = { + 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 {label ?? status}; +} diff --git a/src/modules/draws/draw-subnav.tsx b/src/modules/draws/draw-subnav.tsx new file mode 100644 index 0000000..1c102ae --- /dev/null +++ b/src/modules/draws/draw-subnav.tsx @@ -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 ( + + ); +} diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx new file mode 100644 index 0000000..c3d5e76 --- /dev/null +++ b/src/modules/draws/draws-index-console.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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 ( + + + 期号列表 + 按开奖时间倒序;点期号查看状态、开奖结果与审核。 + + + {/* Grid:桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */} +
+ + setDraftDrawNo(e.target.value)} + /> + +
+ +
+ + {/* 占位:与 Label 行同高,使按钮与输入控件同行对齐 */} +   + +
+ + +
+
+ + {error ? ( +

{error}

+ ) : null} + +
+ + + + 期号 + 状态 + 开奖时间 + 封盘时间 + 操作 + + + + {loading ? ( + + + 加载中… + + + ) : data === null || data.items.length === 0 ? ( + + + 暂无数据 + + + ) : ( + data.items.map((row: AdminDrawListItem) => ( + + {row.draw_no} + + + + {formatDt(row.draw_time)} + {formatDt(row.close_time)} + + + 详情 + + + + )) + )} + +
+
+ + {data ? ( + { + setPerPage(next); + setPage(1); + }} + onPageChange={setPage} + /> + ) : null} +
+
+ ); +} diff --git a/src/modules/draws/meta.ts b/src/modules/draws/meta.ts index 43df088..f535cc4 100644 --- a/src/modules/draws/meta.ts +++ b/src/modules/draws/meta.ts @@ -1,5 +1,5 @@ export const drawsModuleMeta = { segment: "draws", title: "开奖", - description: "期号、开奖结果与时间线(占位)。", + description: "期号列表、状态、开奖结果、审核与发布。", } as const; diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx index 0c4e0c4..68e4b12 100644 --- a/src/modules/wallet/wallet-console.tsx +++ b/src/modules/wallet/wallet-console.tsx @@ -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 ( @@ -165,6 +210,7 @@ function TransferOrdersPanel(): React.ReactElement { const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(20); const [draft, setDraft] = useState(emptyTransferFilters); const [applied, setApplied] = useState(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 ( @@ -240,7 +292,7 @@ function TransferOrdersPanel(): React.ReactElement { setDraft((d) => ({ ...d, playerAccount: e.target.value }))} /> @@ -255,32 +307,47 @@ function TransferOrdersPanel(): React.ReactElement { onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))} /> -
- - setDraft((d) => ({ ...d, createdFrom: e.target.value }))} - /> -
-
- - setDraft((d) => ({ ...d, createdTo: e.target.value }))} +
+ + setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to })) + } />
- setDraft((d) => ({ ...d, statusCsv: e.target.value }))} - /> +
选项 @@ -299,6 +366,9 @@ function TransferOrdersPanel(): React.ReactElement { + @@ -373,32 +443,19 @@ function TransferOrdersPanel(): React.ReactElement {
-
-

- 共 {data.total} 条 · 第 {data.page} /{" "} - {Math.max(1, Math.ceil(data.total / data.per_page))} 页 -

-
- - -
-
+ { + setPerPage(next); + setPage(1); + }} + onPageChange={setPage} + /> ) : null} @@ -412,6 +469,7 @@ function WalletTxnsPanel(): React.ReactElement { const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(20); const [draft, setDraft] = useState(emptyTxnFilters); const [applied, setApplied] = useState(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 ( @@ -488,7 +552,7 @@ function WalletTxnsPanel(): React.ReactElement { setDraft((d) => ({ ...d, playerAccount: e.target.value }))} /> @@ -505,38 +569,75 @@ function WalletTxnsPanel(): React.ReactElement {
- setDraft((d) => ({ ...d, bizType: e.target.value }))} - /> +
- setDraft((d) => ({ ...d, statusCsv: e.target.value }))} - /> +
-
- - setDraft((d) => ({ ...d, createdFrom: e.target.value }))} - /> -
-
- - setDraft((d) => ({ ...d, createdTo: e.target.value }))} +
+ + setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to })) + } />
@@ -556,6 +657,9 @@ function WalletTxnsPanel(): React.ReactElement { + @@ -630,32 +734,19 @@ function WalletTxnsPanel(): React.ReactElement {
-
-

- 共 {data.total} 条 · 第 {data.page} /{" "} - {Math.max(1, Math.ceil(data.total / data.per_page))} 页 -

-
- - -
-
+ { + setPerPage(next); + setPage(1); + }} + onPageChange={setPage} + /> ) : null} diff --git a/src/types/api/admin-draws.ts b/src/types/api/admin-draws.ts new file mode 100644 index 0000000..5354ee4 --- /dev/null +++ b/src/types/api/admin-draws.ts @@ -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; +}; diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 7642032..ab1a6ce 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -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,