feat: 添加日期处理库和日历选择器,更新管理员抽奖模块
This commit is contained in:
116
src/modules/draws/draw-detail-console.tsx
Normal file
116
src/modules/draws/draw-detail-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
src/modules/draws/draw-publish-console.tsx
Normal file
170
src/modules/draws/draw-publish-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/modules/draws/draw-results-console.tsx
Normal file
138
src/modules/draws/draw-results-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/modules/draws/draw-review-console.tsx
Normal file
112
src/modules/draws/draw-review-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/modules/draws/draw-status-badge.tsx
Normal file
23
src/modules/draws/draw-status-badge.tsx
Normal 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>;
|
||||
}
|
||||
44
src/modules/draws/draw-subnav.tsx
Normal file
44
src/modules/draws/draw-subnav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
src/modules/draws/draws-index-console.tsx
Normal file
262
src/modules/draws/draws-index-console.tsx
Normal 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 行同高,使按钮与输入控件同行对齐 */}
|
||||
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const drawsModuleMeta = {
|
||||
segment: "draws",
|
||||
title: "开奖",
|
||||
description: "期号、开奖结果与时间线(占位)。",
|
||||
description: "期号列表、状态、开奖结果、审核与发布。",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user