refactor: 优化配置与奖池页面多语言编辑及管理端列表布局

This commit is contained in:
2026-05-22 16:55:34 +08:00
parent 2d4a23968e
commit 7d01e5c47e
12 changed files with 901 additions and 599 deletions

View File

@@ -3,14 +3,10 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ConfigDocPage } from "@/modules/config/config-doc-page";
import { ConfigSection } from "@/modules/config/config-section";
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
/**
* 奖池:仅保留「侧栏 + 运营配置顶栏」两层导航;池参数与流水在同一页用分区展示。
*/
/** 奖池单页:池参数 + 流水记录,避免 ConfigDocPage / 内层 Card 重复套娃。 */
export function JackpotConfigScreen() {
const { t } = useTranslation("jackpot");
@@ -27,13 +23,20 @@ export function JackpotConfigScreen() {
}, []);
return (
<ConfigDocPage title={t("configTitle")}>
<ConfigSection title={t("poolsSectionTitle")}>
<div className="flex flex-col gap-10">
<section className="space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("poolsSectionTitle")}
</h2>
<JackpotPoolsConsole embedded />
</ConfigSection>
<ConfigSection id="jackpot-records" title={t("recordsSectionTitle")}>
</section>
<section id="jackpot-records" className="scroll-mt-24 space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("recordsSectionTitle")}
</h2>
<JackpotRecordsConsole embedded />
</ConfigSection>
</ConfigDocPage>
</section>
</div>
);
}

View File

@@ -150,149 +150,158 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
}
};
const body = (
<Card className={embedded ? "border-border/60 shadow-none" : undefined}>
{!embedded ? (
<CardHeader>
<CardTitle className="text-base">{t("configTitle")}</CardTitle>
</CardHeader>
const poolList = (
<div className={embedded ? "space-y-4" : "space-y-8"}>
{loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null}
{!loading && items.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
) : null}
<CardContent className="space-y-8">
{loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null}
{!loading && items.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noPoolData")}</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">
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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>
<div className="space-y-1.5">
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</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}`}>{t("triggerThreshold")}</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}`}>{t("payoutRate")}</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}`}>{t("forceTriggerGap")}</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}`}>{t("minBetAmount")}</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 htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
<Input
id={`combo-${p.id}`}
className="font-mono"
value={d.combo_trigger_play_codes}
placeholder="straight,ibox"
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>{t("status")}</Label>
<Select
value={d.status}
onValueChange={(v) => updateDraft(p.id, { status: v ?? "0" })}
>
<SelectTrigger>
<SelectValue>{d.status === "1" ? t("enabled") : t("disabled")}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("disabled")}</SelectItem>
<SelectItem value="1">{t("enabled")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button type="button" disabled={savingId === p.id} onClick={() => void save(p)}>
{savingId === p.id ? t("saving") : t("save")}
</Button>
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<div className="space-y-1.5">
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
<Input
id={`burst-draw-${p.id}`}
className="font-mono"
value={d.manual_burst_draw_id}
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`burst-amount-${p.id}`}>{t("manualBurstAmount")}</Label>
<Input
id={`burst-amount-${p.id}`}
className="font-mono"
value={d.manual_burst_amount}
onChange={(e) => updateDraft(p.id, { manual_burst_amount: e.target.value })}
/>
</div>
<Button
type="button"
variant="destructive"
disabled={burstingId === p.id}
onClick={() => void manualBurst(p)}
>
{burstingId === p.id ? t("processing") : t("manualBurst")}
</Button>
</div>
</div>
{items.map((p) => {
const d = drafts[p.id] ?? toDraft(p);
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>
<div className="grid gap-4 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>
);
})}
</CardContent>
</Card>
<div className="space-y-1.5">
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</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}`}>{t("triggerThreshold")}</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}`}>{t("payoutRate")}</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}`}>{t("forceTriggerGap")}</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}`}>{t("minBetAmount")}</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 htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
<Input
id={`combo-${p.id}`}
className="font-mono"
value={d.combo_trigger_play_codes}
placeholder="straight,ibox"
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`status-${p.id}`}>{t("status")}</Label>
<Select
value={d.status}
onValueChange={(v) => updateDraft(p.id, { status: v ?? "0" })}
>
<SelectTrigger id={`status-${p.id}`} className="w-full">
<SelectValue>{d.status === "1" ? t("enabled") : t("disabled")}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("disabled")}</SelectItem>
<SelectItem value="1">{t("enabled")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end border-t border-border/60 pt-3">
<Button type="button" disabled={savingId === p.id} onClick={() => void save(p)}>
{savingId === p.id ? t("saving") : t("save")}
</Button>
</div>
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<p className="mb-3 text-xs font-medium text-amber-900 dark:text-amber-200">
{t("manualBurst")}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
<Input
id={`burst-draw-${p.id}`}
className="font-mono"
value={d.manual_burst_draw_id}
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
/>
</div>
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-amount-${p.id}`}>{t("manualBurstAmount")}</Label>
<Input
id={`burst-amount-${p.id}`}
className="font-mono"
value={d.manual_burst_amount}
onChange={(e) => updateDraft(p.id, { manual_burst_amount: e.target.value })}
/>
</div>
<Button
type="button"
variant="destructive"
className="shrink-0 sm:ml-auto"
disabled={burstingId === p.id}
onClick={() => void manualBurst(p)}
>
{burstingId === p.id ? t("processing") : t("manualBurst")}
</Button>
</div>
</div>
</div>
);
})}
</div>
);
if (embedded) {
return body;
return poolList;
}
return <ModuleScaffold>{body}</ModuleScaffold>;
return (
<ModuleScaffold>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("configTitle")}</CardTitle>
</CardHeader>
<CardContent>{poolList}</CardContent>
</Card>
</ModuleScaffold>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -32,6 +33,51 @@ type JackpotRecordsConsoleProps = {
embedded?: boolean;
};
/** 表格在 admin-table-shell 内时去掉 Table 组件自带的第二层边框 */
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";
function JackpotRecordTableSection({
title,
tableId,
exportFilename,
exportSheetName,
loading,
hasData,
children,
footer,
}: {
title: string;
tableId: string;
exportFilename: string;
exportSheetName: string;
loading: boolean;
hasData: boolean;
children: React.ReactNode;
footer: React.ReactNode;
}) {
const { t } = useTranslation("common");
return (
<div className="admin-table-shell">
<div className="admin-table-toolbar flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<AdminTableExportButton
tableId={tableId}
filename={exportFilename}
sheetName={exportSheetName}
/>
</div>
{loading && !hasData ? (
<p className="px-4 py-6 text-sm text-muted-foreground">{t("states.loading")}</p>
) : (
<div className={TABLE_IN_SHELL_CLASS}>{children}</div>
)}
{footer ? <div className="px-4 pb-4">{footer}</div> : null}
</div>
);
}
export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) {
const { t } = useTranslation(["jackpot", "common"]);
const payoutExport = useExportLabels("jackpotPayouts");
@@ -147,155 +193,152 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
</Card>
);
const payoutHeader = embedded ? (
<p className="mb-3 text-sm font-semibold">{t("payoutRecords")}</p>
) : (
<CardHeader>
<CardTitle className="text-base">{t("payoutRecords")}</CardTitle>
</CardHeader>
const payoutFooter = 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;
const contributionFooter = 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;
const payoutTable = (
<JackpotRecordTableSection
title={t("payoutRecords")}
tableId="jackpot-payout-table"
exportFilename={payoutExport.filename}
exportSheetName={payoutExport.sheetName}
loading={loadingP}
hasData={payouts != null}
footer={payoutFooter}
>
<Table id="jackpot-payout-table" className="table-fixed">
<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-right">{t("payoutAmount")}</TableHead>
<TableHead className="w-24 text-right">{t("winnerCount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(payouts?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
(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">{triggerTypeText(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>
</JackpotRecordTableSection>
);
const contributionHeader = embedded ? (
<p className="mb-3 text-sm font-semibold">{t("contributionRecords")}</p>
) : (
<CardHeader>
<CardTitle className="text-base">{t("contributionRecords")}</CardTitle>
</CardHeader>
const contributionTable = (
<JackpotRecordTableSection
title={t("contributionRecords")}
tableId="jackpot-contribution-table"
exportFilename={contributionExport.filename}
exportSheetName={contributionExport.sheetName}
loading={loadingC}
hasData={contribs != null}
footer={contributionFooter}
>
<Table id="jackpot-contribution-table" className="table-fixed">
<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-right">{t("contributionAmount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(contribs?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
(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-[12rem] 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>
</JackpotRecordTableSection>
);
const content = (
<>
{filterBlock}
{err ? <p className="text-destructive text-sm">{err}</p> : null}
{err ? <p className="text-destructive mb-4 text-sm">{err}</p> : null}
<Card className={embedded ? "mb-6 border-border/60 shadow-none" : "mb-8"}>
{!embedded ? payoutHeader : null}
<CardContent className={embedded ? "p-0" : undefined}>
{embedded ? payoutHeader : null}
{loadingP && !payouts ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : (
<>
<div className="admin-table-toolbar">
<AdminTableExportButton
tableId="jackpot-payout-table"
filename={payoutExport.filename}
sheetName={payoutExport.sheetName}
/>
</div>
<Table id="jackpot-payout-table">
<TableHeader>
<TableRow>
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("trigger")}</TableHead>
<TableHead className="text-right">{t("payoutAmount")}</TableHead>
<TableHead className="text-right">{t("winnerCount")}</TableHead>
<TableHead>{t("time")}</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">{triggerTypeText(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 className={embedded ? "border-border/60 shadow-none" : undefined}>
{!embedded ? contributionHeader : null}
<CardContent className={embedded ? "p-0" : undefined}>
{embedded ? contributionHeader : null}
{loadingC && !contribs ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : (
<>
<div className="admin-table-toolbar">
<AdminTableExportButton
tableId="jackpot-contribution-table"
filename={contributionExport.filename}
sheetName={contributionExport.sheetName}
/>
</div>
<Table id="jackpot-contribution-table">
<TableHeader>
<TableRow>
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead>
<TableHead className="text-right">{t("contributionAmount")}</TableHead>
<TableHead>{t("time")}</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>
{embedded ? (
<div className="space-y-8">
{payoutTable}
{contributionTable}
</div>
) : (
<div className="space-y-8">
{payoutTable}
{contributionTable}
</div>
)}
</>
);