feat(admin, i18n): enhance reports, draws, config, and player workflows

This commit is contained in:
2026-06-08 17:41:55 +08:00
parent af982bb9f7
commit 7e65c53732
55 changed files with 1986 additions and 804 deletions

View File

@@ -93,7 +93,7 @@ import type {
AdminReportRebateCommissionRow,
} from "@/types/api/admin-reports";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit" | "legacy";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
type ExportFormat = "csv" | "excel";
@@ -192,7 +192,7 @@ const REPORTS: ReportDefinition[] = [
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
{ key: "rebate_commission", category: "legacy", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
{ key: "rebate_commission", category: "profit", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
];
@@ -226,8 +226,6 @@ function categoryTone(category: ReportCategory): string {
return "border-red-200 bg-red-50 text-red-700";
case "audit":
return "border-slate-200 bg-slate-50 text-slate-700";
case "legacy":
return "border-amber-200 bg-amber-50 text-amber-800";
default:
return "border-blue-200 bg-blue-50 text-blue-700";
}
@@ -405,6 +403,90 @@ function resultRowCount(result: ReportResult | null): number {
return result?.rows.length ?? 0;
}
function defaultSummaryCards(
reportKey: ReportKey,
filters: ReportFilters,
t: (key: string) => string,
): StatCard[] {
const periodLabel =
filters.dateFrom && filters.dateTo
? `${filters.dateFrom} ~ ${filters.dateTo}`
: filters.dateFrom || filters.dateTo || t("preview.stats.notQueried");
switch (reportKey) {
case "draw_profit":
return [
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
];
case "daily_profit":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.period"), value: periodLabel },
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
];
case "player_win_loss":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
{ label: t("preview.stats.players"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
];
case "player_transfer":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
{ label: t("preview.stats.transferIn"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.transferOut"), value: t("preview.stats.notQueried") },
];
case "hot_number_risk":
return [
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
{ label: t("fields.number"), value: filters.number || t("preview.stats.notSet") },
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.logs"), value: t("preview.stats.notQueried") },
];
case "play_dimension":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.play"), value: filters.play || t("filterAll") },
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
];
case "sold_out_number":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
{ label: t("preview.stats.currency"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
];
case "rebate_commission":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.play"), value: filters.play || t("filterAll") },
{ label: t("preview.stats.rebate"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.orders"), value: t("preview.stats.notQueried") },
];
case "admin_audit":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") },
{ label: t("preview.stats.modules"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.operators"), value: t("preview.stats.notQueried") },
];
default:
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.exportRows"), value: "0" },
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
];
}
}
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
const { t, i18n } = useTranslation(["reports", "common"]);
const profile = useAdminProfile();
@@ -1426,189 +1508,179 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
};
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
<Card className="admin-list-card self-start">
<CardHeader className="admin-list-header pb-4">
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5 pt-3">
{filteredReports.map((report) => {
const Icon = report.icon;
const active = report.key === selectedReport.key;
return (
<button
key={report.key}
type="button"
onClick={() => setSelectedKey(report.key)}
className={cn(
"flex w-full min-w-0 items-center gap-3 rounded-md border px-3 py-2.5 text-left transition",
active
? "border-primary bg-primary/[0.05] shadow-sm ring-1 ring-primary/15"
: "border-border/80 bg-card hover:border-primary/35 hover:bg-muted/30",
)}
>
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
<Icon className="size-4" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{t(`items.${report.key}.title`)}</span>
</span>
</button>
);
})}
</CardContent>
</Card>
<div className="min-w-0 space-y-5">
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<CardTitle className="admin-list-title">{t("filterPanel")}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{selectedReport.fields.map(renderField)}
</div>
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" onClick={resetFilters}>
{t("reset")}
</Button>
<Button
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
<Card className="admin-list-card">
<CardHeader className="admin-list-header pb-3">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{filteredReports.map((report) => {
const Icon = report.icon;
const active = report.key === selectedReport.key;
return (
<button
key={report.key}
type="button"
disabled={!canViewReports || !selectedReport.connected || loading}
onClick={() => {
setPage(1);
void queryReport();
}}
onClick={() => setSelectedKey(report.key)}
className={cn(
"inline-flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition",
active
? "border-primary bg-primary/[0.06] text-primary shadow-sm"
: "border-border/80 bg-card text-muted-foreground hover:border-primary/35 hover:text-foreground",
)}
>
<Database data-icon="inline-start" />
{loading ? t("querying") : t("query")}
</Button>
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-3 md:grid-cols-4">
{(result?.summary ?? [
{ label: t("preview.stats.records"), value: "-" },
{ label: t("preview.stats.currentPage"), value: "-" },
{ label: t("preview.stats.drawNo"), value: filters.drawNo || "-" },
{ label: t("preview.stats.exportRows"), value: String(resultRowCount(result)) },
]).map((item) => (
<div key={item.label} className={cn("rounded-md border px-4 py-3", statTone(item.tone))}>
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 truncate text-lg font-semibold tabular-nums">{item.value}</div>
</div>
))}
</div>
{selectedReport.key === "rebate_commission" ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
{t("items.rebate_commission.disclaimer", {
defaultValue:
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
<Icon className="size-3.5" aria-hidden />
</span>
<span className="truncate">{t(`items.${report.key}.title`)}</span>
</button>
);
})}
</div>
) : null}
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{selectedReport.fields.map(renderField)}
</div>
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">{t("filterPanel")}</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
{t("reset")}
</Button>
<Button
type="button"
size="sm"
disabled={!canViewReports || !selectedReport.connected || loading}
onClick={() => {
setPage(1);
void queryReport();
}}
>
<Database data-icon="inline-start" />
{loading ? t("querying") : t("query")}
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
</div>
<div className="flex flex-col items-end gap-2">
<p className="text-xs text-muted-foreground">{t("exportServerHint")}</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("csv")}
>
<FileDown data-icon="inline-start" />
{t("formats.csvServer")}
</Button>
<Button
type="button"
disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("excel")}
>
<FileSpreadsheet data-icon="inline-start" />
{t("formats.excelServer")}
</Button>
</div>
{result && result.rows.length > 0 ? (
<>
<div className="grid gap-2 md:grid-cols-4">
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
<div key={item.label} className={cn("rounded-md border px-3 py-2.5", statTone(item.tone))}>
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-0.5 truncate text-base font-semibold tabular-nums">{item.value}</div>
</div>
))}
</div>
{selectedReport.key === "rebate_commission" ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
{t("items.rebate_commission.disclaimer", {
defaultValue:
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
})}
</div>
) : null}
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
</div>
<div className="flex flex-col items-end gap-1.5">
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("csv")}
>
<FileDown data-icon="inline-start" />
{t("formats.csvServer")}
</Button>
<Button
type="button"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("excel")}
>
<FileSpreadsheet data-icon="inline-start" />
{t("formats.excelServer")}
</Button>
</div>
{result && result.rows.length > 0 ? (
<>
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("csv")}
>
{t("formats.csvPreview")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("excel")}
>
{t("formats.excelPreview")}
</Button>
</div>
</>
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("csv")}
>
{t("formats.csvPreview")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("excel")}
>
{t("formats.excelPreview")}
</Button>
</div>
</>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4 pt-4">
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950">
{t("preview.summaryScopeHint")}
</div>
<Table id="reports-preview-table">
<TableHeader>
<TableRow>
<TableHead>{previewColumns.primary}</TableHead>
<TableHead>{previewColumns.secondary}</TableHead>
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
<TableHead>{previewColumns.status}</TableHead>
<TableHead>{previewColumns.extra}</TableHead>
<TableHead>{previewColumns.time}</TableHead>
</TableRow>
</TableHeader>
<TableBody>{renderTable()}</TableBody>
</Table>
<CardContent className="space-y-3 pt-3">
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
{t("preview.summaryScopeHint")}
</div>
<Table id="reports-preview-table">
<TableHeader>
<TableRow>
<TableHead>{previewColumns.primary}</TableHead>
<TableHead>{previewColumns.secondary}</TableHead>
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
<TableHead>{previewColumns.status}</TableHead>
<TableHead>{previewColumns.extra}</TableHead>
<TableHead>{previewColumns.time}</TableHead>
</TableRow>
</TableHeader>
<TableBody>{renderTable()}</TableBody>
</Table>
{result?.meta ? (
<AdminListPaginationFooter
selectId="reports-preview-per-page"
total={result.meta.total}
page={result.meta.page}
lastPage={result.meta.lastPage}
perPage={result.meta.perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</CardContent>
</Card>
</div>
</div>
{result?.meta ? (
<AdminListPaginationFooter
selectId="reports-preview-per-page"
total={result.meta.total}
page={result.meta.page}
lastPage={result.meta.lastPage}
perPage={result.meta.perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</CardContent>
</Card>
<ReportJobsPanel canExport={canExportReports} refreshToken={jobRefreshToken} />
<ReportJobsPanel
canExport={canExportReports}
refreshToken={jobRefreshToken}
reportType={REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey]}
/>
</div>
);
}