feat: 扩展开奖与结算管理,支持手动操作、导出和版本展示
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user