refactor(reports): 优化报表导出和数据处理逻辑

- 异步动态引入 XLSX 库以优化加载性能
- 抽取导出表格为工作簿的异步函数,提高代码复用性
- 使用构建函数封装报表数据行和汇总的生成逻辑
- 替换硬编码数据聚合为调用构建函数,简化代码结构
- 在导出功能中支持异步操作,增强用户体验
- 统一格式化和统计报表数据,提升代码可维护性
This commit is contained in:
2026-06-10 11:06:44 +08:00
parent dfd475856e
commit 1c80a3ae75
2 changed files with 182 additions and 81 deletions

View File

@@ -3,7 +3,6 @@
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import * as XLSX from "xlsx";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -60,6 +59,17 @@ function stripOmittedColumns(table: HTMLTableElement): HTMLTableElement {
return clone; return clone;
} }
async function exportTableToWorkbook(table: HTMLTableElement, sheetName: string, filename: string): Promise<void> {
const XLSX = await import("xlsx");
const exportTable = stripOmittedColumns(table);
const workbook = XLSX.utils.table_to_book(exportTable, {
sheet: sheetName,
raw: true,
});
const safeName = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`;
XLSX.writeFile(workbook, safeName);
}
export function AdminTableExportButton({ export function AdminTableExportButton({
tableId, tableId,
filename, filename,
@@ -73,7 +83,7 @@ export function AdminTableExportButton({
}) { }) {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const onExport = () => { const onExport = async () => {
const table = document.getElementById(tableId); const table = document.getElementById(tableId);
if (!(table instanceof HTMLTableElement)) { if (!(table instanceof HTMLTableElement)) {
toast.error(t("toast.exportFailed")); toast.error(t("toast.exportFailed"));
@@ -81,13 +91,7 @@ export function AdminTableExportButton({
} }
try { try {
const exportTable = stripOmittedColumns(table); await exportTableToWorkbook(table, sheetName, filename);
const workbook = XLSX.utils.table_to_book(exportTable, {
sheet: sheetName,
raw: true,
});
const safeName = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`;
XLSX.writeFile(workbook, safeName);
toast.success(t("toast.exportDone")); toast.success(t("toast.exportDone"));
} catch { } catch {
toast.error(t("toast.exportFailed")); toast.error(t("toast.exportFailed"));

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import * as XLSX from "xlsx";
import { import {
CalendarDays, CalendarDays,
CircleDollarSign, CircleDollarSign,
@@ -279,7 +278,7 @@ function toCsvValue(value: ExportCell): string {
return stringValue; return stringValue;
} }
function exportRows(rows: ExportRow[], filename: string, sheetName: string, format: ExportFormat): void { async function exportRows(rows: ExportRow[], filename: string, sheetName: string, format: ExportFormat): Promise<void> {
if (rows.length === 0) { if (rows.length === 0) {
throw new LotteryApiBizError("no_data", -1, null); throw new LotteryApiBizError("no_data", -1, null);
} }
@@ -295,12 +294,160 @@ function exportRows(rows: ExportRow[], filename: string, sheetName: string, form
return; return;
} }
const XLSX = await import("xlsx");
const worksheet = XLSX.utils.json_to_sheet(rows); const worksheet = XLSX.utils.json_to_sheet(rows);
const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
XLSX.writeFile(workbook, `${filename}.xlsx`); XLSX.writeFile(workbook, `${filename}.xlsx`);
} }
function buildDailyProfitRowsAndSummary(
items: AdminReportDailyProfitRow[],
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "rows" | "summary"> {
let totalBet = 0;
let totalPayout = 0;
let totalGross = 0;
const rows = items.map((item) => {
totalBet += item.total_bet_minor;
totalPayout += item.total_payout_minor;
totalGross += item.approx_house_gross_minor;
return {
business_date: item.business_date,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
approx_house_gross_minor: item.approx_house_gross_minor,
};
});
return {
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
{
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(totalGross, "NPR"),
tone: totalGross >= 0 ? "good" : "bad",
},
],
};
}
function buildPlayerTransferRowsAndSummary(
items: AdminTransferOrderItem[],
total: number,
page: number,
perPage: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
): Pick<Extract<ReportResult, { key: "player_transfer" }>, "rows" | "summary" | "meta"> {
let transferInCount = 0;
let transferOutCount = 0;
const rows = items.map((item) => {
if (item.direction === "in") {
transferInCount += 1;
} else if (item.direction === "out") {
transferOutCount += 1;
}
return {
id: item.id,
transfer_no: item.transfer_no,
player_id: item.player_id,
username: item.username,
nickname: item.nickname,
direction: item.direction,
currency_code: item.currency_code,
amount: item.amount,
status: item.status,
external_ref_no: item.external_ref_no,
fail_reason: item.fail_reason,
created_at: formatExportInstant(item.created_at),
finished_at: formatExportInstant(item.finished_at),
};
});
return {
rows,
meta: { total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)) },
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
{ label: pageScopedLabel("transferIn"), value: String(transferInCount), tone: "good" },
{ label: pageScopedLabel("transferOut"), value: String(transferOutCount), tone: "warn" },
],
};
}
function buildPlayDimensionRowsAndSummary(
items: AdminReportPlayDimensionRow[],
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
let totalBet = 0;
let totalPayout = 0;
const rows = items.map((item) => {
totalBet += item.total_bet_minor;
totalPayout += item.total_payout_minor;
return {
play_code: item.play_code,
dimension: item.dimension,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
approx_house_gross_minor: item.approx_house_gross_minor,
};
});
return {
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
],
};
}
function buildRebateCommissionRowsAndSummary(
items: AdminReportRebateCommissionRow[],
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
): Pick<Extract<ReportResult, { key: "rebate_commission" }>, "rows" | "summary"> {
let totalRebate = 0;
let totalOrders = 0;
const rows = items.map((item) => {
totalRebate += item.total_rebate_minor;
totalOrders += item.order_count;
return {
play_code: item.play_code,
total_rebate_minor: item.total_rebate_minor,
order_count: item.order_count,
ticket_item_count: item.ticket_item_count,
};
});
return {
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, "NPR") },
{ label: pageScopedLabel("orders"), value: String(totalOrders) },
],
};
}
function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta { function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
return { return {
total: meta.total, total: meta.total,
@@ -724,30 +871,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const payload = await getAdminReportDailyProfit( const payload = await getAdminReportDailyProfit(
reportListParams(filters, page, perPage), reportListParams(filters, page, perPage),
); );
const rows = payload.items.map((item) => ({ const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
business_date: item.business_date,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
approx_house_gross_minor: item.approx_house_gross_minor,
}));
const totalBet = payload.items.reduce((sum, item) => sum + item.total_bet_minor, 0);
const totalPayout = payload.items.reduce((sum, item) => sum + item.total_payout_minor, 0);
const totalGross = payload.items.reduce((sum, item) => sum + item.approx_house_gross_minor, 0);
setResult({ setResult({
key: "daily_profit", key: "daily_profit",
raw: payload.items, raw: payload.items,
rows, rows: next.rows,
meta: metaFromList(payload.meta), meta: metaFromList(payload.meta),
summary: [ summary: next.summary,
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
{
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(totalGross, "NPR"),
tone: totalGross >= 0 ? "good" : "bad",
},
],
}); });
break; break;
} }
@@ -792,32 +922,20 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
created_from: filters.dateFrom || undefined, created_from: filters.dateFrom || undefined,
created_to: filters.dateTo || undefined, created_to: filters.dateTo || undefined,
}); });
const rows = payload.items.map((item) => ({ const next = buildPlayerTransferRowsAndSummary(
id: item.id, payload.items,
transfer_no: item.transfer_no, payload.total,
player_id: item.player_id, payload.page,
username: item.username, payload.per_page,
nickname: item.nickname, t,
direction: item.direction, pageScopedLabel,
currency_code: item.currency_code, );
amount: item.amount,
status: item.status,
external_ref_no: item.external_ref_no,
fail_reason: item.fail_reason,
created_at: formatExportInstant(item.created_at),
finished_at: formatExportInstant(item.finished_at),
}));
setResult({ setResult({
key: "player_transfer", key: "player_transfer",
raw: payload.items, raw: payload.items,
rows, rows: next.rows,
meta: { total: payload.total, page: payload.page, perPage: payload.per_page, lastPage: Math.max(1, Math.ceil(payload.total / payload.per_page)) }, meta: next.meta,
summary: [ summary: next.summary,
{ label: t("preview.stats.records"), value: String(payload.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: pageScopedLabel("transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
{ label: pageScopedLabel("transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
],
}); });
break; break;
} }
@@ -913,24 +1031,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const payload = await getAdminReportPlayDimension( const payload = await getAdminReportPlayDimension(
reportListParams(filters, page, perPage), reportListParams(filters, page, perPage),
); );
const rows = payload.items.map((item) => ({ const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
play_code: item.play_code,
dimension: item.dimension,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
approx_house_gross_minor: item.approx_house_gross_minor,
}));
setResult({ setResult({
key: "play_dimension", key: "play_dimension",
raw: payload.items, raw: payload.items,
rows, rows: next.rows,
meta: metaFromList(payload.meta), meta: metaFromList(payload.meta),
summary: [ summary: next.summary,
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
],
}); });
break; break;
} }
@@ -938,23 +1045,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const payload = await getAdminReportRebateCommission( const payload = await getAdminReportRebateCommission(
reportListParams(filters, page, perPage), reportListParams(filters, page, perPage),
); );
const rows = payload.items.map((item) => ({ const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
play_code: item.play_code,
total_rebate_minor: item.total_rebate_minor,
order_count: item.order_count,
ticket_item_count: item.ticket_item_count,
}));
setResult({ setResult({
key: "rebate_commission", key: "rebate_commission",
raw: payload.items, raw: payload.items,
rows, rows: next.rows,
meta: metaFromList(payload.meta), meta: metaFromList(payload.meta),
summary: [ summary: next.summary,
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
{ label: pageScopedLabel("orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
],
}); });
break; break;
} }