Files
lotteryFront/src/features/wallet/wallet-screen.tsx
kang 56ba12fbfc
Some checks failed
lotteryfront CI / build (push) Has been cancelled
refactor(hall): 优化投注格子行数与号码输入逻辑
- 引入默认草稿行数常量及批量生成草稿行函数
- 根据玩法类别调整号码最大输入长度和清洗规则
- 更新号码输入组件以适应动态最大长度和格式规范
- 修正相关回调依赖,确保玩法类别变更时生效
- 改善行状态、占位符和样式传递,增强代码可读性

feat(results): 添加多提供商开奖结果切换功能

- 新增状态管理并展示多提供商按钮切换激活项
- 根据选中的提供商动态展示对应开奖结果数据
- 细化UI显示,支持多提供商名称并列渲染

feat(results): 在结果列表中显示多提供商名称

- 当存在多个提供商结果时展示其名称或代码
- 改善界面信息丰富度,方便用户辨识来源

feat(wallet): 支持账期还款释额类型的流水显示

- 调整账期流水类型文案,精准反映释额含义
- 引入账期时区参数以支持多时区时间格式化
- 自定义信用盘账期释额流水的颜色和文案显示
- 在流水明细和列表中根据时区格式化展示时间

feat(wallet): 支持钱包账期账单区间时区格式化

- 允许传入账期时区参数格式化账单区间时间
- 在账期账单区间显示组件中显示对应时区格式
- 根据玩家配置自动获取并传递账期时区信息
- 条件渲染中信用盘区块支持账期时区参数传递

chore(i18n): 更新中文钱包模块多处文案

- 新增“释额 {{amount}}”等账期释额相关文案
- 修改“账期收款”为“账期还款释额”以符合业务描述

refactor(types): 扩展开奖结果支持多提供商结构

- 新增DrawResultProviderRow类型定义以描述提供商结果
- 在DrawResultListItem中增加provider_results字段支持多结果
- 在玩家信息类型中新增settlement_timezone字段表示账期时区
2026-07-09 15:48:36 +08:00

595 lines
22 KiB
TypeScript

"use client";
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, getWalletSettlementBills } from "@/api/wallet";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { PlayerPanel } from "@/components/layout/player-panel";
import {
TransferInDialog,
TransferOutDialog,
} from "@/features/wallet/wallet-transfer-dialogs";
import { PlayerMoneyDisplay } from "@/components/player-money-display";
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 { formatLotteryInstantInTimeZone, 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;
type FundingModeView = "credit" | "wallet" | "unknown";
function scrollToWalletSection(id: string): void {
if (typeof document === "undefined") return;
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function resolveFundingModeView(
balance: WalletBalanceData | null,
profile: PlayerMeData | null,
): FundingModeView {
if (isCreditFundingPlayer(balance) || isCreditFundingPlayer(profile)) {
return "credit";
}
if (balance?.funding_mode === "wallet" || profile?.funding_mode === "wallet") {
return "wallet";
}
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,
settlementTimeZone?: string,
): string {
const start = item.period_start
? (settlementTimeZone
? formatLotteryInstantInTimeZone(item.period_start, settlementTimeZone)
: formatPlayerInstant(item.period_start)).slice(0, 10)
: "—";
const end = item.period_end
? (settlementTimeZone
? formatLotteryInstantInTimeZone(item.period_end, settlementTimeZone)
: formatPlayerInstant(item.period_end)).slice(0, 10)
: "—";
return `${start} / ${end}`;
}
function CreditSettlementBillsBlock({
data,
currency,
settlementTimeZone,
}: {
data: WalletSettlementBillsData | null;
currency: string;
settlementTimeZone?: 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, settlementTimeZone)}
</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();
const { activeCurrency: currency } = useActivePlayerCurrency();
const { t } = useTranslation("player");
const [transferInOpen, setTransferInOpen] = useState(false);
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);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const filterInitializedRef = useRef(false);
const prevFilterRef = useRef("");
const actionDeepLinkHandledRef = useRef(false);
const sectionDeepLinkHandledRef = useRef(false);
const profile = usePlayerSessionStore((s) => s.profile);
const settlementTimeZone = profile?.settlement_timezone || "UTC";
const fundingModeView = resolveFundingModeView(balance, profile);
const fundingModeKnown = fundingModeView !== "unknown";
const isCreditPlayer = fundingModeView === "credit";
useEffect(() => {
if (actionDeepLinkHandledRef.current || loading) return;
const action = searchParams.get("action");
if (action !== "transfer-in" && action !== "transfer-out") return;
actionDeepLinkHandledRef.current = true;
if (!isCreditPlayer) {
queueMicrotask(() => {
if (action === "transfer-in") {
setTransferInOpen(true);
} else {
setTransferOutOpen(true);
}
});
}
router.replace("/wallet", { scroll: false });
}, [isCreditPlayer, loading, router, searchParams]);
useEffect(() => {
if (sectionDeepLinkHandledRef.current || loading) return;
const section = searchParams.get("section");
if (section !== "logs" && section !== "pending") return;
sectionDeepLinkHandledRef.current = true;
const targetId = section === "pending" ? "wallet-pending" : "wallet-logs";
const timer = window.setTimeout(() => {
scrollToWalletSection(targetId);
router.replace("/wallet", { scroll: false });
}, 120);
return () => window.clearTimeout(timer);
}, [isCreditPlayer, loading, router, searchParams]);
const loadLogs = useCallback(async (targetPage = 1, append = false) => {
const nextLogs = await getWalletLogs({
page: targetPage,
size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined,
currency,
});
setLogs((current) =>
append && current
? { ...nextLogs, items: [...current.items, ...nextLogs.items] }
: nextLogs,
);
return nextLogs;
}, [currency, filter]);
useEffect(() => {
let cancelled = false;
void (async () => {
setError(null);
setLoading(true);
try {
const [b, nextLogs] = await Promise.all([
getWalletBalance({ currency }),
getWalletLogs({
page: 1,
size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined,
currency,
}),
]);
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));
}
} finally {
if (!cancelled) {
setLoading(false);
setLogsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [currency, t]); // eslint-disable-line react-hooks/exhaustive-deps -- 币种切换整页刷新;流水筛选见下方 effect
useEffect(() => {
if (!filterInitializedRef.current) {
filterInitializedRef.current = true;
prevFilterRef.current = filter;
return;
}
if (prevFilterRef.current === filter) {
return;
}
prevFilterRef.current = filter;
let cancelled = false;
setError(null);
setLogsLoading(true);
void (async () => {
try {
const nextLogs = await getWalletLogs({
page: 1,
size: WALLET_LOGS_PAGE_SIZE,
type: filter || undefined,
currency,
});
if (!cancelled) {
setLogs(nextLogs);
}
} catch (e) {
if (!cancelled) {
setError(formatWalletClientError(e, t));
}
} finally {
if (!cancelled) {
setLogsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [currency, filter, t]);
const handleFilterChange = useCallback((value: string) => {
if (value === filter) {
return;
}
setLogs(null);
setLogsLoading(true);
setFilter(value);
}, [filter]);
const refreshAll = useCallback(async () => {
setError(null);
setLogsLoading(true);
try {
// 并行请求余额和日志
const [b] = await Promise.all([
getWalletBalance({ currency }),
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, profile, t]);
useEffect(() => {
const onCurrencyChange = () => void refreshAll();
window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange);
}, [refreshAll]);
const hasMore = logs ? logs.page < getWalletLogsLastPage(logs) : false;
const displayMinor = isCreditPlayer
? Number(balance?.available_balance ?? 0)
: Number(balance?.balance ?? 0);
const panelTitle = !fundingModeKnown
? t("wallet.loadingTitle", { defaultValue: "账户资金" })
: isCreditPlayer
? t("wallet.creditTitle", { defaultValue: "信用" })
: t("wallet.title");
const loadMore = useCallback(() => {
if (!logs || !hasMore || loadingMore) return;
setError(null);
setLoadingMore(true);
void loadLogs(logs.page + 1, true)
.catch((e) => {
setError(formatWalletClientError(e, t));
})
.finally(() => {
setLoadingMore(false);
});
}, [hasMore, loadLogs, loadingMore, logs, t]);
useEffect(() => {
const target = loadMoreRef.current;
if (!target || loading || logsLoading || loadingMore || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
loadMore();
}
},
{ rootMargin: "160px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, loadMore, loading, loadingMore, logsLoading]);
return (
<PlayerPanel title={panelTitle}>
<div className="space-y-3">
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
<p>{error}</p>
<Button
type="button"
className="mt-3 bg-[#e5002c] text-white hover:bg-[#d10028]"
onClick={() => void refreshAll()}
>
{t("actions.retry")}
</Button>
</div>
) : null}
<section className="relative overflow-hidden rounded-xl bg-[#e5002c] px-3 py-4 text-white shadow-[0_10px_28px_rgba(229,0,44,0.25)]">
<Image
src="/entry/image5.png"
alt=""
fill
className="pointer-events-none object-cover object-center"
aria-hidden
/>
<div className="relative flex items-center gap-3">
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-white text-[#d81435] shadow-sm">
<Wallet className="size-7" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white/90">
{fundingModeKnown && isCreditPlayer
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
: fundingModeKnown
? t("wallet.balance")
: t("wallet.loadingBalance", { defaultValue: "正在加载" })}
</p>
{loading || !fundingModeKnown ? (
<Skeleton className="mt-2 h-8 w-44 rounded-md bg-white/25" />
) : (
<PlayerMoneyDisplay
amountMinor={displayMinor}
currency={currency}
className="mt-1 text-white"
/>
)}
<p className="mt-2 text-xs text-white/75">
{!fundingModeKnown
? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" })
: isCreditPlayer
? t("wallet.creditSummary", {
defaultValue: "授信 {{limit}} · 已用 {{used}}",
limit: formatMinorAsCurrency(balance?.credit_limit ?? 0, currency),
used: formatMinorAsCurrency(balance?.used_credit ?? 0, currency),
})
: t("wallet.available", {
amount: formatMinorAsCurrency(balance?.available_balance ?? 0, currency),
})}
</p>
</div>
</div>
</section>
{fundingModeKnown && isCreditPlayer ? (
<CreditSettlementBillsBlock
data={settlementBills}
currency={currency}
settlementTimeZone={settlementTimeZone}
/>
) : null}
{fundingModeKnown && !isCreditPlayer ? (
<div className="grid grid-cols-2 gap-3">
<TransferInDialog
idPrefix="wallet-"
currency={currency}
lotteryMinor={Number(balance?.balance ?? 0)}
mainMinor={
balance?.main_balance === null || balance?.main_balance === undefined
? null
: Number(balance.main_balance)
}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferIn", { defaultValue: "Transfer In" })}
triggerClassName="h-14 rounded-2xl text-base font-black"
open={transferInOpen}
onOpenChange={setTransferInOpen}
/>
<TransferOutDialog
idPrefix="wallet-"
currency={currency}
availableMinor={Number(balance?.available_balance ?? 0)}
onSuccess={refreshAll}
triggerVariant="hall"
triggerLabel={t("wallet.transferOut", { defaultValue: "Transfer Out" })}
triggerClassName="h-14 rounded-2xl text-base font-black"
open={transferOutOpen}
onOpenChange={setTransferOutOpen}
/>
</div>
) : null}
{fundingModeKnown && !isCreditPlayer && (logs?.pending_reconcile?.length ?? 0) > 0 ? (
<section
id="wallet-pending"
className="scroll-mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900"
>
<h2 className="text-sm font-black text-amber-800">
{t("wallet.pendingSectionTitle", { defaultValue: "待对账划转" })}
</h2>
<p className="mt-1 text-xs text-amber-700">
{t("wallet.pendingSectionHint", {
defaultValue: "以下划转仍在与主站对账,请稍后刷新查看结果。",
})}
</p>
<ul className="mt-3 space-y-2">
{logs?.pending_reconcile.map((item) => (
<li
key={item.transfer_no}
className="rounded-xl border border-amber-200 bg-white px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">
{item.direction === "in"
? t("wallet.flow.transfer_in", { defaultValue: "转入" })
: t("wallet.flow.transfer_out", { defaultValue: "转出" })}
</span>
<span className="font-black tabular-nums text-amber-800">
{formatMinorAsCurrency(item.amount, item.currency_code || currency)}
</span>
</div>
<p className="mt-1 font-mono text-[11px] text-amber-600">{item.transfer_no}</p>
</li>
))}
</ul>
</section>
) : null}
<div id="wallet-logs" className="scroll-mt-3">
{fundingModeKnown ? (
<WalletLogsBlock
creditMode={isCreditPlayer}
logs={logs}
logsLoading={loading || logsLoading}
loadingMore={loadingMore}
hasMore={hasMore}
onLoadMore={loadMore}
loadMoreRef={loadMoreRef}
filter={filter}
onFilterChange={handleFilterChange}
currency={currency}
settlementTimeZone={isCreditPlayer ? settlementTimeZone : undefined}
/>
) : (
<Skeleton className="h-40 w-full rounded-lg" />
)}
</div>
</div>
</PlayerPanel>
);
}