feat(wallet): 新增信用盘玩家待结算账单功能
Some checks failed
lotteryfront CI / build (push) Has been cancelled

- 新增接口 getWalletSettlementBills 获取信用盘玩家账期账单数据
- 在钱包日志块中添加业务类型标签支持,增强日志类型显示
- WalletScreen 中增加账期账单数据的加载和展示逻辑
- 新增 CreditSettlementBillsBlock 组件用于展示账期账单摘要和详情列表
- 支持账单状态显示和账期区间格式化
- 仅信用盘玩家显示账期账单模块,其他类型玩家隐藏
- 新增 wallet-settlement-bills.ts 类型定义文件,完善账期账单数据结构
This commit is contained in:
2026-07-01 15:35:13 +08:00
parent efb1bcdda8
commit 646c139697
4 changed files with 218 additions and 6 deletions

View File

@@ -5,6 +5,7 @@ import type {
WalletTransferResultData,
} from "@/types/api/wallet-transfer";
import type { GetWalletLogsParams, WalletLogsData } from "@/types/api/wallet-logs";
import type { WalletSettlementBillsData } from "@/types/api/wallet-settlement-bills";
export type GetWalletBalanceParams = {
/** Query `currency`,不传则用玩家默认币种 */
@@ -31,6 +32,13 @@ export function getWalletLogs(
);
}
/** `GET /api/v1/wallet/settlement-bills`(信用盘玩家待收/待付账单) */
export function getWalletSettlementBills(): Promise<WalletSettlementBillsData> {
return lotteryRequest.get<WalletSettlementBillsData>(
`/wallet/settlement-bills`,
);
}
/** `POST /api/v1/wallet/transfer-in`(主站 → 彩票) */
export function postWalletTransferIn(
body: WalletTransferBody,
@@ -58,3 +66,8 @@ export type {
WalletLogsData,
WalletPendingTransfer,
} from "@/types/api/wallet-logs";
export type {
WalletSettlementBillItem,
WalletSettlementBillsData,
WalletSettlementBillsSummary,
} from "@/types/api/wallet-settlement-bills";

View File

@@ -48,11 +48,33 @@ const FLOW_LABEL_FALLBACKS: Record<string, string> = {
"wallet.creditFlow.reversal": "退本释额",
};
const CREDIT_BIZ_LABEL_FALLBACKS: Record<string, string> = {
bet_hold: "下注占用",
bet_hold_release: "退本释额",
game_settlement_loss: "亏损入账",
game_settlement_win: "中奖释额",
game_settlement_reversal: "结算冲正",
settlement_confirm: "账期收款",
settlement_payout: "账期付款",
rebate_accrued: "账期回水",
rebate_in_bill: "回水已入账单",
rebate_settled: "回水已结清",
};
export function logTypeLabel(
type: string,
t?: (key: string, options?: { defaultValue?: string }) => string,
creditMode = false,
bizType?: string,
): string {
if (creditMode && bizType) {
const bizKey = `wallet.creditBiz.${bizType}`;
const fallback = CREDIT_BIZ_LABEL_FALLBACKS[bizType];
if (fallback) {
return t?.(bizKey, { defaultValue: fallback }) ?? fallback;
}
}
const prefix = creditMode ? "wallet.creditFlow." : "wallet.flow.";
const key = `${prefix}${type}`;
return t?.(key, { defaultValue: FLOW_LABEL_FALLBACKS[key] ?? type }) ?? type;
@@ -227,7 +249,7 @@ export function LogRow({
<div className="flex items-center gap-2">
<span className={isIn ? "size-2.5 rounded-full bg-emerald-500" : "size-2.5 rounded-full bg-[#e5002c]"} aria-hidden />
<p className="truncate text-base font-black leading-tight text-[#101a33]">
{logTypeLabel(item.type, t, creditMode)}
{logTypeLabel(item.type, t, creditMode, item.biz_type)}
</p>
</div>
<p className="mt-1.5 text-xs font-medium text-slate-500">
@@ -253,12 +275,18 @@ export function LogRow({
<div className="mt-3 flex items-center justify-between rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
<span className="font-semibold text-slate-500">
{creditMode ? t("wallet.creditAvailableAfter") : t("wallet.balanceAfter")}
{creditMode
? item.affects_available_credit === false
? t("wallet.creditBillOnly", { defaultValue: "账期记录" })
: t("wallet.creditAvailableAfter")
: t("wallet.balanceAfter")}
</span>
<span className="font-mono font-black tabular-nums text-[#32518d]">
{item.balance_after != null
? formatMinorAsCurrency(item.balance_after, ccy)
: "—"}
: creditMode && item.affects_available_credit === false
? t("wallet.noCreditChange", { defaultValue: "不改变可用信用" })
: "—"}
</span>
</div>
</li>

View File

@@ -1,12 +1,12 @@
"use client";
import { Wallet } from "lucide-react";
import { ArrowDownLeft, ArrowUpRight, Wallet } from "lucide-react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
import { getWalletBalance, getWalletLogs, getWalletSettlementBills } from "@/api/wallet";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { PlayerPanel } from "@/components/layout/player-panel";
@@ -19,11 +19,16 @@ import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { formatMinorAsCurrency } from "@/lib/money";
import { formatPlayerInstant } from "@/lib/player-datetime";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { formatWalletClientError } from "@/lib/wallet-api-error";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import type { WalletBalanceData } from "@/types/api/wallet-balance";
import { getWalletLogsLastPage, type WalletLogsData } from "@/types/api/wallet-logs";
import type {
WalletSettlementBillItem,
WalletSettlementBillsData,
} from "@/types/api/wallet-settlement-bills";
import type { PlayerMeData } from "@/types/api/player-me";
const WALLET_LOGS_PAGE_SIZE = 10;
@@ -49,6 +54,128 @@ function resolveFundingModeView(
return "unknown";
}
function settlementStatusLabel(
status: string,
t: (key: string, options?: { defaultValue?: string }) => string,
): string {
const fallback: Record<string, string> = {
pending_confirm: "待确认",
confirmed: "待收付",
partial_paid: "部分收付",
overdue: "已逾期",
settled: "已结清",
};
return t(`wallet.settlementStatus.${status}`, { defaultValue: fallback[status] ?? status });
}
function formatPeriodRange(item: WalletSettlementBillItem): string {
const start = item.period_start ? formatPlayerInstant(item.period_start).slice(0, 10) : "—";
const end = item.period_end ? formatPlayerInstant(item.period_end).slice(0, 10) : "—";
return `${start} / ${end}`;
}
function CreditSettlementBillsBlock({
data,
currency,
}: {
data: WalletSettlementBillsData | null;
currency: string;
}) {
const { t } = useTranslation("player");
const summary = data?.summary;
const netPending = Number(summary?.net_pending ?? 0);
const pendingCount = Number(summary?.pending_count ?? 0);
const netDirection = netPending >= 0 ? "receivable" : "payable";
return (
<section className="rounded-2xl border border-[#dce7f7] bg-white px-3 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)]">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-black text-[#0b3f96]">
{t("wallet.settlementTitle", { defaultValue: "我的账期账单" })}
</h2>
<p className="mt-1 text-xs text-slate-500">
{t("wallet.settlementHint", { defaultValue: "只显示未结清账单,中奖超出授信的部分在这里结算。" })}
</p>
</div>
<span className="rounded-full bg-[#f1f6ff] px-2.5 py-1 text-xs font-black text-[#32518d]">
{t("wallet.settlementPendingCount", { defaultValue: "{{count}}笔", count: pendingCount })}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="rounded-xl bg-emerald-50 px-3 py-2">
<p className="text-xs font-bold text-emerald-700">
{t("wallet.pendingReceivable", { defaultValue: "待收" })}
</p>
<p className="mt-1 font-mono text-sm font-black tabular-nums text-emerald-700">
{formatMinorAsCurrency(summary?.pending_receivable ?? 0, currency)}
</p>
</div>
<div className="rounded-xl bg-red-50 px-3 py-2">
<p className="text-xs font-bold text-red-700">
{t("wallet.pendingPayable", { defaultValue: "待付" })}
</p>
<p className="mt-1 font-mono text-sm font-black tabular-nums text-red-700">
{formatMinorAsCurrency(summary?.pending_payable ?? 0, currency)}
</p>
</div>
</div>
<div className="mt-2 rounded-xl bg-[#f8fbff] px-3 py-2 text-xs">
<span className="font-semibold text-slate-500">
{netDirection === "receivable"
? t("wallet.netReceivable", { defaultValue: "净待收" })
: t("wallet.netPayable", { defaultValue: "净待付" })}
</span>
<span className={netDirection === "receivable" ? "ml-2 font-mono font-black text-emerald-700" : "ml-2 font-mono font-black text-red-700"}>
{formatMinorAsCurrency(Math.abs(netPending), currency)}
</span>
</div>
{data === null ? (
<Skeleton className="mt-3 h-20 w-full rounded-xl" />
) : data.items.length === 0 ? (
<p className="mt-3 rounded-xl border border-dashed border-[#dce7f7] py-5 text-center text-sm text-slate-500">
{t("wallet.noSettlementBills", { defaultValue: "暂无待结算账单" })}
</p>
) : (
<ul className="mt-3 space-y-2">
{data.items.map((item) => {
const receivable = item.direction === "receivable";
return (
<li key={item.id} className="rounded-xl border border-[#e1eaf6] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className={receivable ? "rounded-full bg-emerald-100 p-1 text-emerald-700" : "rounded-full bg-red-100 p-1 text-red-700"}>
{receivable ? <ArrowDownLeft className="size-3.5" /> : <ArrowUpRight className="size-3.5" />}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-black text-[#101a33]">
{receivable
? t("wallet.agentPaysPlayer", { defaultValue: "代理应付玩家" })
: t("wallet.playerPaysAgent", { defaultValue: "玩家应付代理" })}
</p>
<p className="mt-0.5 text-[11px] text-slate-500">{formatPeriodRange(item)}</p>
</div>
</div>
<div className="shrink-0 text-right">
<p className={receivable ? "font-mono text-sm font-black text-emerald-700" : "font-mono text-sm font-black text-red-700"}>
{formatMinorAsCurrency(item.unpaid_amount, currency)}
</p>
<p className="mt-0.5 text-[11px] text-slate-500">{settlementStatusLabel(item.status, t)}</p>
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
);
}
export function WalletScreen() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -58,6 +185,7 @@ export function WalletScreen() {
const [transferOutOpen, setTransferOutOpen] = useState(false);
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
const [logs, setLogs] = useState<WalletLogsData | null>(null);
const [settlementBills, setSettlementBills] = useState<WalletSettlementBillsData | null>(null);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
const [logsLoading, setLogsLoading] = useState(false);
@@ -141,6 +269,12 @@ export function WalletScreen() {
if (cancelled) return;
setBalance(b);
setLogs(nextLogs);
if (isCreditFundingPlayer(b) || isCreditFundingPlayer(profile)) {
const bills = await getWalletSettlementBills();
if (!cancelled) setSettlementBills(bills);
} else {
setSettlementBills(null);
}
} catch (e) {
if (!cancelled) {
setError(formatWalletClientError(e, t));
@@ -220,13 +354,18 @@ export function WalletScreen() {
loadLogs(1, false),
]);
setBalance(b);
if (isCreditFundingPlayer(b) || isCreditFundingPlayer(profile)) {
setSettlementBills(await getWalletSettlementBills());
} else {
setSettlementBills(null);
}
} catch (e) {
setError(formatWalletClientError(e, t));
} finally {
setLogsLoading(false);
setLoading(false);
}
}, [currency, loadLogs, t]);
}, [currency, loadLogs, profile, t]);
useEffect(() => {
const onCurrencyChange = () => void refreshAll();
@@ -338,6 +477,10 @@ export function WalletScreen() {
</div>
</section>
{fundingModeKnown && isCreditPlayer ? (
<CreditSettlementBillsBlock data={settlementBills} currency={currency} />
) : null}
{fundingModeKnown && !isCreditPlayer ? (
<div className="grid grid-cols-2 gap-3">
<TransferInDialog

View File

@@ -0,0 +1,28 @@
export type WalletSettlementBillDirection = "receivable" | "payable";
export type WalletSettlementBillItem = {
id: number;
settlement_period_id: number;
period_start: string | null;
period_end: string | null;
gross_win_loss: number;
rebate_amount: number;
net_amount: number;
paid_amount: number;
unpaid_amount: number;
direction: WalletSettlementBillDirection;
status: string;
};
export type WalletSettlementBillsSummary = {
pending_receivable: number;
pending_payable: number;
net_pending: number;
pending_count: number;
};
export type WalletSettlementBillsData = {
items: WalletSettlementBillItem[];
summary: WalletSettlementBillsSummary;
funding_mode: string;
};