feat(api, ui, i18n): 增强奖池管理与钱包功能

新增奖池余额调整与调整记录查询相关 API,提升后台对奖池的管理与控制能力。
更新奖池与钱包相关多语言文案,新增余额调整与转账完成提示信息,提升用户理解与反馈体验。
优化奖池管理相关 UI 组件,新增余额调整功能并改进页面布局,提升操作易用性。
重构相关组件以整合新功能,并进一步优化后台管理界面的整体用户体验。
This commit is contained in:
2026-05-26 14:59:41 +08:00
parent 60271d87fb
commit eb83bcf360
23 changed files with 881 additions and 228 deletions

View File

@@ -29,7 +29,11 @@ export function JackpotConfigScreen() {
<JackpotPoolsConsole embedded />
</AdminPageCard>
<AdminPageCard id="jackpot-records" title={t("recordsSectionTitle")}>
<AdminPageCard
id="jackpot-records"
title={t("recordsSectionTitle")}
description={t("recordsSectionDescription")}
>
<JackpotRecordsConsole embedded />
</AdminPageCard>
</div>

View File

@@ -4,8 +4,10 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getAdminJackpotPoolAdjustments,
getAdminJackpotPools,
postAdminJackpotManualBurst,
postAdminJackpotPoolAdjustment,
putAdminJackpotPool,
} from "@/api/admin-jackpot";
import { useConfirmAction } from "@/hooks/use-confirm-action";
@@ -29,10 +31,17 @@ import {
import { toast } from "sonner";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
import type { AdminJackpotPoolAdjustmentRow, AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type Draft = {
current_amount: string;
contribution_rate: string;
trigger_threshold: string;
payout_rate: string;
@@ -43,9 +52,14 @@ type Draft = {
manual_burst_draw_id: string;
};
type AdjustmentDraft = {
direction: "increase" | "decrease";
amount: string;
reason: string;
};
function toDraft(p: AdminJackpotPoolRow): Draft {
return {
current_amount: formatAdminMinorDecimal(p.current_amount, p.currency_code),
contribution_rate: String(p.contribution_rate),
trigger_threshold: formatAdminMinorDecimal(p.trigger_threshold, p.currency_code),
payout_rate: String(p.payout_rate),
@@ -74,6 +88,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
const [savingId, setSavingId] = useState<number | null>(null);
const [burstingId, setBurstingId] = useState<number | null>(null);
const [confirmBurstPoolId, setConfirmBurstPoolId] = useState<number | null>(null);
const [adjustmentDrafts, setAdjustmentDrafts] = useState<Record<number, AdjustmentDraft>>({});
const [adjustmentRows, setAdjustmentRows] = useState<Record<number, AdminJackpotPoolAdjustmentRow[]>>({});
const [adjustingId, setAdjustingId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -81,10 +98,21 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
const res = await getAdminJackpotPools();
setItems(res.items);
const d: Record<number, Draft> = {};
const adjDrafts: Record<number, AdjustmentDraft> = {};
const adjRows: Record<number, AdminJackpotPoolAdjustmentRow[]> = {};
for (const p of res.items) {
d[p.id] = toDraft(p);
adjDrafts[p.id] = { direction: "increase", amount: "", reason: "" };
try {
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
adjRows[p.id] = ledger.items;
} catch {
adjRows[p.id] = [];
}
}
setDrafts(d);
setAdjustmentDrafts(adjDrafts);
setAdjustmentRows(adjRows);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
} finally {
@@ -105,13 +133,19 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
}));
};
const updateAdjustmentDraft = (id: number, patch: Partial<AdjustmentDraft>) => {
setAdjustmentDrafts((prev) => ({
...prev,
[id]: { ...(prev[id] ?? { direction: "increase", amount: "", reason: "" }), ...patch },
}));
};
const save = async (p: AdminJackpotPoolRow) => {
const d = drafts[p.id];
if (!d) return;
setSavingId(p.id);
try {
await putAdminJackpotPool(p.id, {
current_amount: parseAdminMajorToMinor(d.current_amount, p.currency_code) ?? 0,
contribution_rate: Number(d.contribution_rate),
trigger_threshold: parseAdminMajorToMinor(d.trigger_threshold, p.currency_code) ?? 0,
payout_rate: Number(d.payout_rate),
@@ -132,6 +166,43 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
}
};
const submitAdjustment = async (p: AdminJackpotPoolRow) => {
const adj = adjustmentDrafts[p.id];
if (!adj) return;
const minor = parseAdminMajorToMinor(adj.amount, p.currency_code);
if (minor === null || minor <= 0) {
toast.error(t("adjustmentAmountInvalid"));
return;
}
const trimmedReason = adj.reason.trim();
if (trimmedReason.length < 3) {
toast.error(t("adjustmentReasonRequired"));
return;
}
const amountDelta = adj.direction === "decrease" ? -minor : minor;
setAdjustingId(p.id);
try {
const res = await postAdminJackpotPoolAdjustment(p.id, {
amount_delta: amountDelta,
reason: trimmedReason,
});
toast.success(t("adjustmentSuccess"));
setItems((prev) =>
prev.map((row) =>
row.id === p.id ? { ...row, current_amount: res.pool.current_amount } : row,
),
);
updateAdjustmentDraft(p.id, { amount: "", reason: "" });
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
setAdjustmentRows((prev) => ({ ...prev, [p.id]: ledger.items }));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("adjustmentFailed"));
} finally {
setAdjustingId(null);
}
};
const manualBurst = async (p: AdminJackpotPoolRow) => {
const d = drafts[p.id];
if (!d) return;
@@ -164,22 +235,96 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
) : null}
{items.map((p) => {
const d = drafts[p.id] ?? toDraft(p);
const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" };
const ledger = adjustmentRows[p.id] ?? [];
return (
<div
key={p.id}
className="space-y-4 rounded-xl border border-border/60 bg-muted/10 p-4"
>
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
<fieldset disabled={!canManageJackpot} className="grid gap-4 border-0 p-0 sm:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label>
<Input
id={`amt-${p.id}`}
className="font-mono"
value={d.current_amount}
onChange={(e) => updateDraft(p.id, { current_amount: e.target.value })}
/>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
<p className="text-muted-foreground font-mono text-xs">
{t("displayBalance", {
amount: formatAdminMinorDecimal(p.current_amount, p.currency_code),
})}
</p>
</div>
{canManageJackpot ? (
<div className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4">
<p className="text-sm font-medium">{t("balanceAdjustmentTitle")}</p>
<p className="text-muted-foreground text-xs">{t("balanceAdjustmentHint")}</p>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label>{t("adjustmentDirection")}</Label>
<Select
value={adj.direction}
onValueChange={(value: "increase" | "decrease") =>
updateAdjustmentDraft(p.id, { direction: value })
}
>
<SelectTrigger className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
<Input
id={`adj-amt-${p.id}`}
className="font-mono"
value={adj.amount}
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
/>
</div>
<div className="space-y-1.5 sm:col-span-2 xl:col-span-2">
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
<Textarea
id={`adj-reason-${p.id}`}
rows={2}
value={adj.reason}
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="secondary"
disabled={adjustingId === p.id}
onClick={() =>
requestConfirm({
title: t("confirmAdjustmentTitle"),
description: t("confirmAdjustmentDescription"),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => submitAdjustment(p),
})
}
>
{adjustingId === p.id ? t("processing") : t("submitAdjustment")}
</Button>
</div>
{ledger.length > 0 ? (
<div className="space-y-2 border-t border-border/60 pt-3">
<p className="text-xs font-medium">{t("recentAdjustments")}</p>
<ul className="text-muted-foreground space-y-1 font-mono text-xs">
{ledger.map((row) => (
<li key={row.id}>
{row.adjustment_no} · {row.amount_delta > 0 ? "+" : ""}
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)} ·{" "}
{row.reason}
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
<fieldset disabled={!canManageJackpot} className="grid gap-4 border-0 p-0 sm:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
<Input

View File

@@ -37,6 +37,10 @@ type JackpotRecordsConsoleProps = {
const TABLE_IN_SHELL_CLASS =
"[&_[data-slot=table-container]]:rounded-none [&_[data-slot=table-container]]:border-0 [&_[data-slot=table-container]]:bg-transparent [&_[data-slot=table-container]]:shadow-none";
/** 流水表左对齐文本列,避免 table-fixed 拉宽空白列 */
const TABLE_LAYOUT_CLASS =
"w-full min-w-[42rem] text-left [&_[data-slot=table-head]]:text-left [&_[data-slot=table-cell]]:text-left";
function JackpotRecordTableSection({
title,
tableId,
@@ -60,7 +64,7 @@ function JackpotRecordTableSection({
return (
<div className="admin-table-shell">
<div className="admin-table-toolbar flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/70 bg-muted/20 px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<AdminTableExportButton
tableId={tableId}
@@ -154,9 +158,9 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
return translated === key ? value : translated;
};
const filterBlock = embedded ? (
<div className="admin-list-toolbar mb-0 border-t-0 pt-0">
<div className="admin-list-field max-w-xs flex-1">
const filterFields = (
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex w-full min-w-0 max-w-sm flex-col gap-1.5">
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
<Input
id="jk-draw"
@@ -164,34 +168,27 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
value={drawNo}
onChange={(e) => setDrawNo(e.target.value)}
placeholder={t("optional")}
onKeyDown={(e) => {
if (e.key === "Enter") {
applyDraw();
}
}}
/>
</div>
<div className="admin-list-actions">
<Button type="button" onClick={applyDraw}>
{t("apply")}
</Button>
</div>
<Button type="button" className="shrink-0 sm:self-end" onClick={applyDraw}>
{t("apply")}
</Button>
</div>
);
const filterBlock = embedded ? (
filterFields
) : (
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("filter")}</CardTitle>
</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">{t("drawNo")}</Label>
<Input
id="jk-draw"
className="font-mono"
value={drawNo}
onChange={(e) => setDrawNo(e.target.value)}
placeholder={t("optional")}
/>
</div>
<Button type="button" onClick={applyDraw}>
{t("apply")}
</Button>
</CardContent>
<CardContent>{filterFields}</CardContent>
</Card>
);
@@ -237,21 +234,21 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
hasData={payouts != null}
footer={payoutFooter}
>
<Table id="jackpot-payout-table" className="table-fixed">
<Table id="jackpot-payout-table" className={TABLE_LAYOUT_CLASS}>
<TableHeader>
<TableRow>
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
<TableHead className="w-28">{t("trigger")}</TableHead>
<TableHead className="w-32 text-center">{t("payoutAmount")}</TableHead>
<TableHead className="w-24 text-center">{t("winnerCount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead>
<TableHead className="w-16 whitespace-nowrap">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("drawNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("trigger")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("payoutAmount")}</TableHead>
<TableHead className="w-24 whitespace-nowrap text-right">{t("winnerCount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(payouts?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -261,10 +258,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
<TableCell className="text-center font-mono text-xs tabular-nums">
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-center tabular-nums">{r.winner_count}</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>
@@ -286,21 +283,21 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
hasData={contribs != null}
footer={contributionFooter}
>
<Table id="jackpot-contribution-table" className="table-fixed">
<Table id="jackpot-contribution-table" className={TABLE_LAYOUT_CLASS}>
<TableHeader>
<TableRow>
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
<TableHead className="w-[11rem]">{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead>
<TableHead className="w-32 text-center">{t("contributionAmount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead>
<TableHead className="w-16 whitespace-nowrap">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("drawNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("ticketNo")}</TableHead>
<TableHead className="max-w-[10rem] whitespace-nowrap">{t("player")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("contributionAmount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(contribs?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -310,8 +307,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<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-[12rem] truncate text-xs">{r.player_username ?? "—"}</TableCell>
<TableCell className="text-center font-mono text-xs tabular-nums">
<TableCell className="max-w-[10rem] truncate text-xs" title={r.player_username ?? undefined}>
{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">
@@ -330,17 +329,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
{filterBlock}
{err ? <p className="text-destructive text-sm">{err}</p> : null}
{embedded ? (
<div className="space-y-8">
{payoutTable}
{contributionTable}
</div>
) : (
<div className="space-y-8">
{payoutTable}
{contributionTable}
</div>
)}
<div className="space-y-6">
{payoutTable}
{contributionTable}
</div>
</>
);