feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
421
src/modules/wallet/wallet-txns-panel.tsx
Normal file
421
src/modules/wallet/wallet-txns-panel.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminWalletTransactions } from "@/api/admin-wallet";
|
||||
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WalletCellMonoId } from "@/modules/wallet/wallet-cell-mono-id";
|
||||
import {
|
||||
WALLET_FILTER_ALL,
|
||||
WALLET_TXN_BIZ_OPTIONS,
|
||||
WALLET_TXN_STATUS_OPTIONS,
|
||||
emptyTxnFilters,
|
||||
txnFiltersFromSearchParams,
|
||||
walletAdminSelectDisplayedLabel,
|
||||
type TxnFilters,
|
||||
} from "@/modules/wallet/wallet-filter-utils";
|
||||
import { walletStatusLabel, walletTxnBizTypeLabel } from "@/modules/wallet/wallet-labels";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminWalletTxnListData } from "@/types/api/admin-wallet";
|
||||
|
||||
function parsePlayerId(raw: string): number | undefined {
|
||||
if (raw.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
const id = Number(raw);
|
||||
return !Number.isNaN(id) && id > 0 ? id : undefined;
|
||||
}
|
||||
|
||||
export function WalletTxnsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const { t: tSettlement } = useTranslation("settlementCenter");
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("walletTransactions");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const searchParams = useSearchParams();
|
||||
const urlFilterKey = searchParams.toString();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [draft, setDraft] = useState<TxnFilters>(() => txnFiltersFromSearchParams(searchParams));
|
||||
const [applied, setApplied] = useState<TxnFilters>(() => txnFiltersFromSearchParams(searchParams));
|
||||
|
||||
useEffect(() => {
|
||||
const next = txnFiltersFromSearchParams(searchParams);
|
||||
setDraft(next);
|
||||
setApplied(next);
|
||||
setPage(1);
|
||||
}, [urlFilterKey, searchParams]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const d = await getAdminWalletTransactions({
|
||||
page,
|
||||
per_page: perPage,
|
||||
abnormal: applied.abnormalOnly || undefined,
|
||||
player_id: parsePlayerId(applied.playerId),
|
||||
player_account: applied.playerAccount.trim() || undefined,
|
||||
txn_no: applied.txnNo.trim() || undefined,
|
||||
external_ref_no: applied.externalRefNo.trim() || undefined,
|
||||
created_from: applied.createdFrom.trim() || undefined,
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
biz_type: applied.bizType.trim() || undefined,
|
||||
status: applied.abnormalOnly ? undefined : applied.statusCsv.trim() || undefined,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, applied, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const runSearch = () => {
|
||||
const next = draft.abnormalOnly ? { ...draft, statusCsv: "" } : { ...draft };
|
||||
setDraft(next);
|
||||
setApplied(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyTxnFilters);
|
||||
setApplied(emptyTxnFilters);
|
||||
setPage(1);
|
||||
router.replace(pathname);
|
||||
};
|
||||
|
||||
const showLedgerColumn = data?.items.some((row) => row.ledger_source === "credit_ledger") ?? false;
|
||||
const txnTableColSpan = showLedgerColumn ? 13 : 12;
|
||||
const hasDeepLink = applied.txnNo !== "" || applied.playerId !== "" || applied.externalRefNo !== "";
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold">{t("walletTransactions")}</h2>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="wallet-transactions-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8" disabled={loading} onClick={() => void load()}>
|
||||
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
|
||||
{t("refreshCurrentPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-b border-border/60 px-3 py-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-no" className="text-xs">
|
||||
{t("txnNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="tx-no"
|
||||
className="h-8"
|
||||
placeholder={t("search")}
|
||||
value={draft.txnNo}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-ext" className="text-xs">
|
||||
{t("externalRefNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="tx-ext"
|
||||
className="h-8"
|
||||
placeholder={t("search")}
|
||||
value={draft.externalRefNo}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-account" className="text-xs">
|
||||
{t("playerAccount")}
|
||||
</Label>
|
||||
<Input
|
||||
id="tx-account"
|
||||
className="h-8"
|
||||
placeholder={t("playerAccountPlaceholder")}
|
||||
value={draft.playerAccount}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-player" className="text-xs">
|
||||
{t("playerId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="tx-player"
|
||||
className="h-8"
|
||||
inputMode="numeric"
|
||||
placeholder={t("playerIdOptional")}
|
||||
value={draft.playerId}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-biz" className="text-xs">
|
||||
{t("bizType")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={
|
||||
draft.bizType === "" || !WALLET_TXN_BIZ_OPTIONS.some((o) => o.value === draft.bizType)
|
||||
? WALLET_FILTER_ALL
|
||||
: draft.bizType
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="tx-biz" className="h-8 w-full">
|
||||
<SelectValue>
|
||||
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
|
||||
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-status" className="text-xs">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
disabled={draft.abnormalOnly}
|
||||
value={
|
||||
draft.statusCsv === "" || !WALLET_TXN_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
|
||||
? WALLET_FILTER_ALL
|
||||
: draft.statusCsv
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="tx-status" className="h-8 w-full">
|
||||
<SelectValue>
|
||||
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
|
||||
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-2">
|
||||
<AdminDateRangeField
|
||||
id="tx-created-range"
|
||||
label={t("requestDateRange")}
|
||||
from={draft.createdFrom}
|
||||
to={draft.createdTo}
|
||||
onRangeChange={(r) =>
|
||||
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end gap-2">
|
||||
<label className="flex min-h-8 cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={draft.abnormalOnly}
|
||||
onCheckedChange={(v) => {
|
||||
const abnormalOnly = v === true;
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
abnormalOnly,
|
||||
statusCsv: abnormalOnly ? "" : d.statusCsv,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{t("abnormalOnlyPending")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" className="h-8" onClick={() => runSearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" className="h-8" onClick={() => resetFilters()}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-3">
|
||||
{applied.abnormalOnly ? (
|
||||
<p className="rounded-md border border-amber-200/80 bg-amber-50 px-3 py-2 text-sm text-amber-950">
|
||||
{t("txnAbnormalFilterActive")}
|
||||
</p>
|
||||
) : null}
|
||||
{hasDeepLink ? (
|
||||
<p className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
|
||||
{t("txnDeepLinkActive")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell overflow-x-auto rounded-md border">
|
||||
<Table id="wallet-transactions-table" className="min-w-[1080px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[9.5rem]">{t("txnNo")}</TableHead>
|
||||
<TableHead className="min-w-[8rem]">{t("externalRefNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
{showLedgerColumn ? (
|
||||
<TableHead className="whitespace-nowrap">{t("ledgerChannel")}</TableHead>
|
||||
) : null}
|
||||
<TableHead className="min-w-[6rem]">{t("type")}</TableHead>
|
||||
<TableHead className="min-w-[5.5rem] text-right">{t("amount")}</TableHead>
|
||||
<TableHead className="min-w-[4rem] text-center">{t("direction")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
|
||||
<TableHead className="min-w-[8rem] whitespace-nowrap">{t("requestTime")}</TableHead>
|
||||
<TableHead className="min-w-[8rem] whitespace-nowrap">{t("finishedTime")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={txnTableColSpan} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={txnTableColSpan} />
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<TableRow key={`${row.ledger_source ?? "wallet"}-${row.id}`}>
|
||||
<TableCell className="align-middle">
|
||||
<WalletCellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
|
||||
</TableCell>
|
||||
<TableCell className="align-middle">
|
||||
<WalletCellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
{showLedgerColumn ? (
|
||||
<TableCell className="align-middle whitespace-nowrap">
|
||||
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="align-middle text-xs">
|
||||
<span
|
||||
className="line-clamp-2"
|
||||
title={walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
|
||||
>
|
||||
{walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={adminMoneyCellClassName("align-middle text-right text-xs")}>
|
||||
<AdminTableMoney>
|
||||
{row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
|
||||
</AdminTableMoney>
|
||||
</TableCell>
|
||||
<TableCell className="align-middle text-center text-xs text-muted-foreground">
|
||||
{row.direction === 1 ? t("in") : t("out")}
|
||||
</TableCell>
|
||||
<TableCell className="align-middle whitespace-nowrap">
|
||||
<AdminStatusBadge status={row.status}>{walletStatusLabel(row.status, t)}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="align-middle whitespace-nowrap font-mono text-xs text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="align-middle whitespace-nowrap font-mono text-xs text-muted-foreground">
|
||||
{formatTs(row.updated_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transactions-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user