feat: 更新管理员导航,添加结算和奖池模块,并优化活动匹配逻辑

This commit is contained in:
2026-05-11 15:35:41 +08:00
parent 0103d25426
commit f083b28fc6
19 changed files with 1226 additions and 5 deletions

View File

@@ -0,0 +1,209 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getAdminJackpotPools, putAdminJackpotPool } from "@/api/admin-jackpot";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { formatAdminMinorUnits } from "@/lib/money";
import { toast } from "sonner";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
type Draft = {
current_amount: string;
contribution_rate: string;
trigger_threshold: string;
payout_rate: string;
force_trigger_draw_gap: string;
min_bet_amount: string;
status: string;
};
function toDraft(p: AdminJackpotPoolRow): Draft {
return {
current_amount: String(p.current_amount),
contribution_rate: String(p.contribution_rate),
trigger_threshold: String(p.trigger_threshold),
payout_rate: String(p.payout_rate),
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
min_bet_amount: String(p.min_bet_amount),
status: String(p.status),
};
}
export function JackpotPoolsConsole() {
const [items, setItems] = useState<AdminJackpotPoolRow[]>([]);
const [drafts, setDrafts] = useState<Record<number, Draft>>({});
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getAdminJackpotPools();
setItems(res.items);
const d: Record<number, Draft> = {};
for (const p of res.items) {
d[p.id] = toDraft(p);
}
setDrafts(d);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
const updateDraft = (id: number, patch: Partial<Draft>) => {
setDrafts((prev) => ({
...prev,
[id]: { ...prev[id], ...patch },
}));
};
const save = async (p: AdminJackpotPoolRow) => {
const d = drafts[p.id];
if (!d) return;
setSavingId(p.id);
try {
await putAdminJackpotPool(p.id, {
current_amount: Number.parseInt(d.current_amount, 10),
contribution_rate: Number(d.contribution_rate),
trigger_threshold: Number.parseInt(d.trigger_threshold, 10),
payout_rate: Number(d.payout_rate),
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
min_bet_amount: Number.parseInt(d.min_bet_amount, 10),
status: Number.parseInt(d.status, 10),
});
toast.success("已保存");
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
} finally {
setSavingId(null);
}
};
return (
<ModuleScaffold>
<Card>
<CardHeader>
<CardTitle className="text-base">Jackpot </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-8">
{loading ? <p className="text-muted-foreground text-sm"></p> : null}
{!loading && items.length === 0 ? (
<p className="text-muted-foreground text-sm"></p>
) : null}
{items.map((p) => {
const d = drafts[p.id] ?? toDraft(p);
return (
<div key={p.id} className="space-y-4 rounded-lg border border-border p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
<span className="text-xs text-muted-foreground">
{formatAdminMinorUnits(p.current_amount, p.currency_code)}
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor={`amt-${p.id}`}></Label>
<Input
id={`amt-${p.id}`}
className="font-mono"
value={d.current_amount}
onChange={(e) => updateDraft(p.id, { current_amount: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`cr-${p.id}`}> 01</Label>
<Input
id={`cr-${p.id}`}
className="font-mono"
value={d.contribution_rate}
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`th-${p.id}`}></Label>
<Input
id={`th-${p.id}`}
className="font-mono"
value={d.trigger_threshold}
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`pr-${p.id}`}> 01</Label>
<Input
id={`pr-${p.id}`}
className="font-mono"
value={d.payout_rate}
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`gap-${p.id}`}></Label>
<Input
id={`gap-${p.id}`}
className="font-mono"
value={d.force_trigger_draw_gap}
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`min-${p.id}`}></Label>
<Input
id={`min-${p.id}`}
className="font-mono"
value={d.min_bet_amount}
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Select
value={d.status}
onValueChange={(v) => updateDraft(p.id, { status: v ?? "0" })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button type="button" disabled={savingId === p.id} onClick={() => void save(p)}>
{savingId === p.id ? "保存中…" : "保存"}
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
</ModuleScaffold>
);
}

View File

@@ -0,0 +1,236 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminJackpotContributionsData,
AdminJackpotPayoutLogsData,
} from "@/types/api/admin-jackpot";
export function JackpotRecordsConsole() {
const formatDt = useAdminDateTimeFormatter();
const [drawNo, setDrawNo] = useState("");
const [appliedDrawNo, setAppliedDrawNo] = useState("");
const [payouts, setPayouts] = useState<AdminJackpotPayoutLogsData | null>(null);
const [pPage, setPPage] = useState(1);
const [pPer, setPPer] = useState(15);
const [contribs, setContribs] = useState<AdminJackpotContributionsData | null>(null);
const [cPage, setCPage] = useState(1);
const [cPer, setCPer] = useState(15);
const [loadingP, setLoadingP] = useState(true);
const [loadingC, setLoadingC] = useState(true);
const [err, setErr] = useState<string | null>(null);
const loadPayouts = useCallback(async () => {
setLoadingP(true);
try {
const d = await getAdminJackpotPayoutLogs({
page: pPage,
per_page: pPer,
draw_no: appliedDrawNo.trim() || undefined,
});
setPayouts(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "派彩记录加载失败");
} finally {
setLoadingP(false);
}
}, [pPage, pPer, appliedDrawNo]);
const loadContribs = useCallback(async () => {
setLoadingC(true);
try {
const d = await getAdminJackpotContributions({
page: cPage,
per_page: cPer,
draw_no: appliedDrawNo.trim() || undefined,
});
setContribs(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "蓄水记录加载失败");
} finally {
setLoadingC(false);
}
}, [cPage, cPer, appliedDrawNo]);
useEffect(() => {
queueMicrotask(() => {
void loadPayouts();
});
}, [loadPayouts]);
useEffect(() => {
queueMicrotask(() => {
void loadContribs();
});
}, [loadContribs]);
const applyDraw = () => {
setAppliedDrawNo(drawNo);
setPPage(1);
setCPage(1);
};
return (
<ModuleScaffold>
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex max-w-xs flex-1 flex-col gap-1.5">
<Label htmlFor="jk-draw"></Label>
<Input
id="jk-draw"
className="font-mono"
value={drawNo}
onChange={(e) => setDrawNo(e.target.value)}
placeholder="可选"
/>
</div>
<Button type="button" onClick={applyDraw}>
</Button>
</CardContent>
</Card>
{err ? <p className="text-destructive mb-4 text-sm">{err}</p> : null}
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-base">Jackpot </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{loadingP && !payouts ? (
<p className="text-muted-foreground text-sm"></p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(payouts?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{r.trigger_type}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-right tabular-nums">{r.winner_count}</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatDt(r.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{payouts ? (
<AdminListPaginationFooter
selectId="jk-payout-per"
total={payouts.meta.total}
page={payouts.meta.current_page}
lastPage={payouts.meta.last_page}
perPage={payouts.meta.per_page}
loading={loadingP}
onPerPageChange={(n) => {
setPPer(n);
setPPage(1);
}}
onPageChange={setPPage}
/>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Jackpot </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{loadingC && !contribs ? (
<p className="text-muted-foreground text-sm"></p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(contribs?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs">
{r.player_username ?? "—"}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatDt(r.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{contribs ? (
<AdminListPaginationFooter
selectId="jk-contrib-per"
total={contribs.meta.total}
page={contribs.meta.current_page}
lastPage={contribs.meta.last_page}
perPage={contribs.meta.per_page}
loading={loadingC}
onPerPageChange={(n) => {
setCPer(n);
setCPage(1);
}}
onPageChange={setCPage}
/>
) : null}
</CardContent>
</Card>
</ModuleScaffold>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const LINKS: { href: string; label: string }[] = [
{ href: "/admin/jackpot/pools", label: "奖池配置" },
{ href: "/admin/jackpot/records", label: "记录" },
];
export function JackpotSubNav() {
const pathname = usePathname();
return (
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3" aria-label="Jackpot 子导航">
{LINKS.map(({ href, label }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
className={cn(
"rounded-md px-3 py-1.5 text-sm transition-colors",
active
? "bg-primary text-primary-foreground"
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{label}
</Link>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,4 @@
export const jackpotModuleMeta = {
title: "Jackpot",
description: "奖池配置与蓄水 / 派彩记录",
} as const;