feat: 扩展开奖与结算管理,支持手动操作、导出和版本展示

This commit is contained in:
2026-05-16 18:00:57 +08:00
parent 34f9175304
commit fae8c1ae01
21 changed files with 1148 additions and 410 deletions

View File

@@ -2,19 +2,30 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { getAdminDraw } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import {
getAdminDraw,
postAdminCancelDraw,
postAdminManualCloseDraw,
postAdminReopenDraw,
postAdminRunDrawRng,
} from "@/api/admin-draws";
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, 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 { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
import { DrawStatusBadge } from "./draw-status-badge";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -27,10 +38,14 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
export function DrawDetailConsole({ drawId }: { drawId: string }) {
const idNum = Number(drawId);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const isSuperAdmin = profile?.permissions?.includes("prd.admin_user.manage") ?? false;
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminDrawShowData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [acting, setActing] = useState<string | null>(null);
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
@@ -50,6 +65,20 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
}
}, [idNum]);
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
if (!Number.isFinite(idNum)) return;
setActing(name);
try {
await action();
toast.success(`${name}成功`);
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : `${name}失败`);
} finally {
setActing(null);
}
}
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
@@ -124,6 +153,58 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<p className="text-sm text-muted-foreground">
/ / RNG / /
</p>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
onClick={() => void runAction("手动封盘", () => postAdminManualCloseDraw(idNum))}
>
{acting === "手动封盘" ? "处理中…" : "手动封盘"}
</Button>
<Button
type="button"
variant="outline"
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
onClick={() => void runAction("取消期号", () => postAdminCancelDraw(idNum))}
>
{acting === "取消期号" ? "处理中…" : "未开奖前取消"}
</Button>
<Button
type="button"
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
onClick={() => void runAction("RNG开奖", () => postAdminRunDrawRng(idNum))}
>
{acting === "RNG开奖" ? "生成中…" : "RNG 自动生成"}
</Button>
{isSuperAdmin ? (
<Button
type="button"
variant="destructive"
disabled={acting !== null || data.status !== "cooldown"}
onClick={() => void runAction("重开", () => postAdminReopenDraw(idNum))}
>
{acting === "重开" ? "处理中…" : "冷静期重开"}
</Button>
) : null}
<Button
type="button"
variant="outline"
disabled={acting !== null || !["settling", "cooldown"].includes(data.status)}
onClick={() => void runAction("触发结算", () => postAdminRunDrawSettlement(idNum))}
>
{acting === "触发结算" ? "处理中…" : "触发结算"}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -17,12 +18,14 @@ import {
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import { toast } from "sonner";
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [settling, setSettling] = useState(false);
const load = useCallback(async () => {
if (!Number.isFinite(idNum) || idNum < 1) {
@@ -42,6 +45,20 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
}
}, [idNum]);
async function runSettlement(): Promise<void> {
if (!Number.isFinite(idNum) || idNum < 1) return;
setSettling(true);
try {
const res = await postAdminRunDrawSettlement(idNum);
toast.success(res.ran ? "已触发结算" : "当前状态不可结算或已处理");
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "触发结算失败");
} finally {
setSettling(false);
}
}
useEffect(() => {
queueMicrotask(() => {
void load();
@@ -103,6 +120,9 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
</Button>
<Button type="button" size="sm" disabled={settling} onClick={() => void runSettlement()}>
{settling ? "处理中…" : "触发结算"}
</Button>
<Link
href="/admin/settlement-batches"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}

View File

@@ -155,7 +155,8 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
</div>
<p className="font-mono text-xs text-muted-foreground">
RNG {batch.rng_seed_hash ?? "—"}
{batch.source_type === "manual" ? "人工录入" : "RNG 自动生成"} ·
{batch.items.length}/23 · RNG {batch.rng_seed_hash ?? "—"}
</p>
</CardContent>
<CardFooter className="justify-end gap-2">

View File

@@ -104,7 +104,7 @@ function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
<CardHeader className="pb-2">
<CardTitle className="text-base"> v{batch.result_version}</CardTitle>
<p className="font-mono text-xs text-muted-foreground">
RNG {batch.rng_seed_hash ?? "—"} · {batch.confirmed_at ?? "—"}
{batch.source_type === "manual" ? "人工录入" : "RNG"} · RNG {batch.rng_seed_hash ?? "—"} · {batch.confirmed_at ?? "—"}
</p>
</CardHeader>
<CardContent className="overflow-x-auto pt-0">

View File

@@ -2,10 +2,12 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import { getAdminDrawResultBatches, postAdminCreateManualResultBatch } from "@/api/admin-draws";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
@@ -23,6 +25,22 @@ import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
const RESULT_SLOTS = [
{ prize_type: "first", prize_index: 0, label: "头奖" },
{ prize_type: "second", prize_index: 0, label: "二奖" },
{ prize_type: "third", prize_index: 0, label: "三奖" },
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "starter",
prize_index: i,
label: `特别奖 ${i + 1}`,
})),
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "consolation",
prize_index: i,
label: `安慰奖 ${i + 1}`,
})),
] as const;
export function DrawReviewConsole({ drawId }: { drawId: string }) {
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
@@ -32,6 +50,10 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [savingManual, setSavingManual] = useState(false);
const [manualNumbers, setManualNumbers] = useState<string[]>(
() => RESULT_SLOTS.map(() => ""),
);
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
@@ -62,6 +84,33 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
data,
]);
async function saveManualDraft(): Promise<void> {
if (!Number.isFinite(idNum)) return;
const invalid = manualNumbers.some((n) => !/^[0-9]{4}$/.test(n));
if (invalid) {
toast.error("请完整输入 23 组 4 位数字");
return;
}
setSavingManual(true);
try {
const res = await postAdminCreateManualResultBatch(idNum, {
items: RESULT_SLOTS.map((slot, i) => ({
prize_type: slot.prize_type,
prize_index: slot.prize_index,
number_4d: manualNumbers[i],
})),
});
toast.success(`已保存草稿 v${res.batch.result_version},等待确认发布`);
setManualNumbers(RESULT_SLOTS.map(() => ""));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
} finally {
setSavingManual(false);
}
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
}
@@ -71,14 +120,59 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<DrawStatusBadge status={data.draw_status} />
</p>
</CardHeader>
<CardContent>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<DrawStatusBadge status={data.draw_status} /> ·
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{RESULT_SLOTS.map((slot, i) => (
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">{slot.label}</span>
<Input
inputMode="numeric"
maxLength={4}
value={manualNumbers[i]}
disabled={!canManageDraw || savingManual}
placeholder="0000"
className="font-mono"
onChange={(e) => {
const next = e.target.value.replace(/\D/g, "").slice(0, 4);
setManualNumbers((old) => old.map((v, idx) => (idx === i ? next : v)));
}}
/>
</label>
))}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={savingManual}
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
>
</Button>
<Button
type="button"
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
onClick={() => void saveManualDraft()}
>
{savingManual ? "保存中…" : "保存草稿"}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
{pending.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
pending_review
@@ -116,7 +210,8 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
</TableBody>
</Table>
)}
</CardContent>
</Card>
</CardContent>
</Card>
</div>
);
}

View File

@@ -2,8 +2,9 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { getAdminDraws } from "@/api/admin-draws";
import { getAdminDraws, postAdminGenerateDrawPlan } from "@/api/admin-draws";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -67,6 +68,7 @@ export function DrawsIndexConsole() {
const [appliedStatus, setAppliedStatus] = useState("");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<number>(20);
const [generating, setGenerating] = useState(false);
const drawStatusTriggerLabel = useMemo(
() =>
@@ -102,6 +104,19 @@ export function DrawsIndexConsole() {
}
}, [page, perPage, appliedDrawNo, appliedStatus]);
async function generatePlan(): Promise<void> {
setGenerating(true);
try {
const res = await postAdminGenerateDrawPlan();
toast.success(`已生成 ${res.created} 期,当前缓冲 ${res.upcoming}/${res.buffer_target}`);
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "生成失败");
} finally {
setGenerating(false);
}
}
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
@@ -111,8 +126,11 @@ export function DrawsIndexConsole() {
return (
<Card>
<CardHeader className="space-y-1">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-lg"></CardTitle>
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
{generating ? "生成中…" : "批量生成期开奖计划"}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* Grid桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}
@@ -194,22 +212,26 @@ export function DrawsIndexConsole() {
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
<TableCell colSpan={9} className="text-muted-foreground">
</TableCell>
</TableRow>
) : data === null || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
<TableCell colSpan={9} className="text-muted-foreground">
</TableCell>
</TableRow>
@@ -217,11 +239,26 @@ export function DrawsIndexConsole() {
data.items.map((row: AdminDrawListItem) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.draw_no}</TableCell>
<TableCell className="text-sm">{formatDt(row.start_time)}</TableCell>
<TableCell className="text-sm">{formatDt(row.close_time)}</TableCell>
<TableCell className="text-sm">{formatDt(row.draw_time)}</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 font-mono text-xs tabular-nums">
{row.total_bet_minor ?? "—"}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{row.total_payout_minor ?? "—"}
</TableCell>
<TableCell
className={cn(
"text-right font-mono text-xs tabular-nums",
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
)}
>
{row.profit_loss_minor ?? "—"}
</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/draws/${row.id}`}