feat(player): improve wallet activity and session handling
Some checks failed
lotteryfront CI / build (push) Has been cancelled

This commit is contained in:
wchino
2026-07-22 20:53:43 +08:00
parent 508b28df22
commit 827f369eb6
40 changed files with 925 additions and 349 deletions

View File

@@ -257,10 +257,9 @@ export function HallBetPreviewDialog({
</td>
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
<span className="block font-mono text-base font-black text-slate-950">{ln.number}</span>
<span className="text-[11px] font-bold text-[#0755c7]">{playLabel(ln.play_code, t)}</span>
</td>
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-slate-700">
{ln.play_code}
<td className="border-r border-[#e8eef7] px-2 py-3 text-center font-semibold text-[#0755c7]">
{playLabel(ln.play_code, t)}
</td>
<td className="border-r border-[#e8eef7] px-2 py-3 text-center">
<span className="inline-flex rounded-full bg-[#eef5ff] px-2 py-0.5 font-black text-[#0b56b7]">

View File

@@ -55,13 +55,16 @@ export function digitSlotOptions(category: PlayHallCategory): number[] {
return [0, 1, 2, 3];
}
function digitSlotLabel(category: PlayHallCategory, slot: number): string {
const labels: Record<PlayHallCategory, Record<number, string>> = {
D2: { 2: "十", 3: "个" },
D3: { 1: "百", 2: "十", 3: "个" },
D4: { 0: "千", 1: "百", 2: "十", 3: "个" },
};
return labels[category][slot] ?? String(slot + 1);
function digitSlotLabel(
category: PlayHallCategory,
slot: number,
t: HallTranslate,
): string {
if (!digitSlotOptions(category).includes(slot)) return String(slot + 1);
return t(`hall.table.digitPosition.${slot}`, {
defaultValue: String(10 ** (3 - slot)),
});
}
export function playColumnHeaderLabel(
@@ -72,7 +75,7 @@ export function playColumnHeaderLabel(
): string {
if (digitSlot !== undefined) {
const kind = play.play_code === "digit_big" ? "big" : "small";
return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot)}`;
return `${t(`hall.table.digitShort.${kind}`)}·${digitSlotLabel(category, digitSlot, t)}`;
}
return playLabel(play.play_code, t);
}

View File

@@ -55,6 +55,7 @@ import {
} from "@/features/hall/selection-type";
import type { HallDrawLiveSnapshot } from "@/features/hall/use-hall-draw-live";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { triggerWalletPollingAfterBet } from "@/hooks/use-wallet-polling";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { getLotteryEcho } from "@/lib/lottery-echo";
@@ -436,11 +437,9 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
}
}, [currencyParam]);
useEffect(() => {
queueMicrotask(() => {
void loadCatalog();
void refreshWallet();
});
useAsyncEffect(() => {
void loadCatalog();
void refreshWallet();
}, [loadCatalog, refreshWallet]);
useEffect(() => {
@@ -1500,18 +1499,22 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
<section className="space-y-3" aria-label={t("hall.aria")}>
{jackpot?.enabled ? (
<div className="rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-white to-[#f8fbff] px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-[11px] font-black uppercase tracking-normal text-amber-700">
<div className="relative overflow-hidden rounded-xl border border-[#d6b74e]/70 bg-[linear-gradient(115deg,#06183c_0%,#0a3a89_50%,#071f50_100%)] px-3 py-3 text-white shadow-[0_10px_28px_rgba(7,46,112,0.2)] sm:px-4">
<div className="pointer-events-none absolute -left-10 top-1/2 size-28 -translate-y-1/2 rounded-full bg-[#2d79ff]/25 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_2.8s_ease-in-out_infinite]" />
<div className="pointer-events-none absolute -right-5 -top-16 size-36 rounded-full bg-[#f4ca54]/20 blur-2xl motion-safe:animate-[jackpot-amount-row-glow_3.2s_ease-in-out_infinite]" />
<div className="pointer-events-none absolute -inset-y-10 -left-1/3 w-1/3 rotate-12 bg-gradient-to-r from-transparent via-white/20 to-transparent blur-sm motion-safe:animate-[jackpot-strip-sweep_4s_ease-in-out_infinite]" />
<div className="relative flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="w-fit bg-gradient-to-r from-[#fff1ad] via-white to-[#e2bd45] bg-[length:200%_auto] bg-clip-text text-[11px] font-black uppercase tracking-[0.16em] text-transparent motion-safe:animate-[jackpot-shimmer_3s_linear_infinite]">
{t("results.jackpotLabel", { defaultValue: "Jackpot" })}
</p>
<p className="font-mono text-xl font-black tabular-nums text-[#07459f]">
{formatMinorAsCurrency(jackpot.current_amount_minor, jackpot.currency_code)}
<p className="mt-0.5 truncate font-mono text-xl font-black tabular-nums text-white drop-shadow-[0_0_12px_rgba(245,216,122,0.35)] sm:text-2xl motion-safe:animate-[jackpot-amount-glow_2.4s_ease-in-out_infinite]">
{formatMinorAmount(jackpot.current_amount_minor)}
</p>
</div>
{jackpot.draws_since_last_burst !== null ? (
<p className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-800">
<p className="ml-auto rounded-full border border-[#f4d66f]/35 bg-white/10 px-3 py-1.5 text-xs font-bold text-[#ffe8a0] shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-sm">
{t("results.jackpotGap", {
count: jackpot.draws_since_last_burst,
})}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { getPlayEffective } from "@/api/play";
import { Badge } from "@/components/ui/badge";
@@ -99,10 +100,8 @@ export function HallPlayCatalogPanel() {
}
}, [currencyParam, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
useAsyncEffect(() => {
void load();
}, [load]);
useEffect(() => {

View File

@@ -1,3 +1,5 @@
import type { CSSProperties } from "react";
import { formatMinorAmount } from "@/lib/money";
import { cn } from "@/lib/utils";
@@ -7,6 +9,11 @@ export type HallSummaryItem = {
totalMinor: number;
};
function balancedColumnCount(itemCount: number, maxColumns: number) {
const rowCount = Math.max(1, Math.ceil(itemCount / maxColumns));
return Math.max(1, Math.ceil(itemCount / rowCount));
}
export function HallPlaySummaryGrid({
items,
className,
@@ -14,12 +21,18 @@ export function HallPlaySummaryGrid({
items: HallSummaryItem[];
className?: string;
}) {
const gridStyle = {
"--hall-summary-columns": balancedColumnCount(items.length, 10),
"--hall-summary-columns-wide": balancedColumnCount(items.length, 16),
} as CSSProperties;
return (
<div
className={cn(
"grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-[repeat(auto-fit,minmax(3.5rem,1fr))]",
"hall-summary-grid grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10",
className,
)}
style={gridStyle}
>
{items.map((item) => {
const hasValue = item.totalMinor > 0;

View File

@@ -16,7 +16,6 @@ import {
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { useApiQuery } from "@/hooks/use-api-query";
import { useIsMobile } from "@/hooks/use-mobile";
import { formatMinorAsCurrency } from "@/lib/money";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference";
import { cn } from "@/lib/utils";
@@ -97,6 +96,7 @@ export function HallWalletStrip() {
alt=""
fill
sizes="(max-width: 1023px) 100vw, 1400px"
loading="eager"
className="pointer-events-none object-cover object-center"
aria-hidden
/>
@@ -111,20 +111,9 @@ export function HallWalletStrip() {
>
<Wallet className={cn(isMobile ? "size-5.5" : "size-5.5")} aria-hidden />
</div>
<div
className={cn(
"min-w-0 flex-1",
!isMobile && "flex items-center gap-3 overflow-hidden",
)}
>
<div
className={cn(
"min-w-0",
!isMobile &&
"flex shrink-0 flex-col items-start gap-1 overflow-hidden xl:flex-row xl:items-baseline xl:gap-2",
)}
>
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-xs xl:text-sm")}>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<p className={cn("shrink-0 font-semibold text-white/90", isMobile ? "text-[13px]" : "text-xs xl:text-sm")}>
{label}
</p>
{loading ? (
@@ -138,27 +127,14 @@ export function HallWalletStrip() {
<PlayerMoneyDisplay
amountMinor={headlineMinor}
currency={currency}
showCurrencyCode={false}
className={cn(
"text-white",
isMobile ? "mt-0.5" : "text-lg xl:text-xl",
isMobile ? "text-lg" : "text-lg xl:text-xl",
)}
/>
)}
</div>
{isCreditPlayer && !loading && balance ? (
<p
className={cn(
"text-white/80",
isMobile ? "mt-1 text-[11px]" : "hidden min-w-0 truncate text-xs xl:block",
)}
>
{t("wallet.creditSummary", {
defaultValue: "授信 {{limit}} · 已用 {{used}}",
limit: formatMinorAsCurrency(balance.credit_limit ?? 0, currency),
used: formatMinorAsCurrency(balance.used_credit ?? 0, currency),
})}
</p>
) : null}
</div>
</div>

View File

@@ -18,6 +18,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { ticketDetailHref } from "@/features/orders/group-ticket-items";
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { formatPlayerInstant } from "@/lib/player-datetime";
@@ -118,10 +119,8 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) {
}
}, [ticketNo, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
useAsyncEffect(() => {
void load();
}, [load]);
useEffect(() => {

View File

@@ -28,6 +28,7 @@ import { OrderMetaLine } from "@/features/orders/order-meta-line";
import { StatusDot, ticketStatusDisplay } from "@/features/orders/ticket-item-status";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useIsMobile } from "@/hooks/use-mobile";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { formatMinorAsCurrency } from "@/lib/money";
@@ -61,6 +62,12 @@ function providerLabel(providerName?: string | null, providerCode?: string | nul
return providerName || providerCode || "-";
}
function groupProviderLabel(items: TicketItemListRow[]): string {
return Array.from(
new Set(items.map((item) => providerLabel(item.provider_name, item.provider_code))),
).join(", ");
}
export function TicketOrdersListScreen() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -170,7 +177,7 @@ export function TicketOrdersListScreen() {
[fromDate, queryDrawNo, queryNumber, queryStatuses, t, toDate],
);
useEffect(() => {
useAsyncEffect(() => {
void getDrawCurrent()
.then((res) => {
const tz = res.data?.schedule_timezone?.trim();
@@ -186,7 +193,7 @@ export function TicketOrdersListScreen() {
[scheduleTimezone],
);
useEffect(() => {
useAsyncEffect(() => {
if (!initialLoadDone.current) {
initialLoadDone.current = true;
void fetchPage(1, false);
@@ -606,6 +613,8 @@ export function TicketOrdersListScreen() {
</div>
<p className="mt-2 text-xs font-semibold text-[#7890b8]">
{t("orders.betItems")} · {group.items.length}
{" · "}
{groupProviderLabel(group.items)}
</p>
{group.status === "partial_failed" ? (
<p className="mt-2 text-xs font-bold text-amber-700">
@@ -750,7 +759,14 @@ export function TicketOrdersListScreen() {
) : null}
</p>
</TableCell>
<TableCell className="px-3 py-2.5" />
<TableCell className="px-3 py-2.5 align-middle">
<p
className="truncate text-sm font-semibold text-[#0b56b7]"
title={groupProviderLabel(group.items)}
>
{groupProviderLabel(group.items)}
</p>
</TableCell>
<TableCell className="px-3 py-2.5 text-right align-middle">
<p className="text-sm font-black tabular-nums text-slate-900">
{formatMinorAsCurrency(group.total_bet_amount, cur)}

View File

@@ -1,9 +1,8 @@
"use client";
import { useEffect } from "react";
import { getPublicCurrencies } from "@/api/currency";
import { getPlayerMe } from "@/api/player";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { ensureCurrencyCatalogLoaded } from "@/hooks/use-currency-catalog";
import { loadCurrencyDisplayFormat } from "@/lib/currency-display-settings";
import { usePlayerSessionStore } from "@/stores/player-session-store";
@@ -16,9 +15,8 @@ export function HydratePlayerAuth(): null {
(state) => state.restoreBearerToken,
);
const setProfile = usePlayerSessionStore((state) => state.setProfile);
const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies);
useEffect(() => {
useAsyncEffect(() => {
usePlayerSessionStore.getState().reconcileSelectedCurrency();
void loadCurrencyDisplayFormat();
const refreshFormat = setInterval(() => {
@@ -30,8 +28,7 @@ export function HydratePlayerAuth(): null {
try {
if (usePlayerSessionStore.getState().currencies.length === 0) {
try {
const currencies = await getPublicCurrencies();
setCurrencies(currencies.items);
await ensureCurrencyCatalogLoaded();
} catch {
// 币种元数据失败时不影响登录态恢复。
}
@@ -50,7 +47,7 @@ export function HydratePlayerAuth(): null {
return () => {
clearInterval(refreshFormat);
};
}, [restoreBearerToken, setCurrencies, setProfile]);
}, [restoreBearerToken, setProfile]);
return null;
}

View File

@@ -0,0 +1,55 @@
"use client";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
import { usePlayerSessionStore } from "@/stores/player-session-store";
function subscribeHydration(onStoreChange: () => void): () => void {
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) onStoreChange();
});
return () => {
cancelled = true;
};
}
function getHydratedSnapshot(): boolean {
return true;
}
function getServerHydratedSnapshot(): boolean {
return false;
}
export function PlayerAuthGate({ children }: { children: ReactNode }): ReactNode {
const router = useRouter();
const bearerToken = usePlayerSessionStore((state) => state.bearerToken);
const hasHydrated = useSyncExternalStore(
subscribeHydration,
getHydratedSnapshot,
getServerHydratedSnapshot,
);
const redirectStarted = useRef(false);
useEffect(() => {
if (!hasHydrated || bearerToken || redirectStarted.current) return;
redirectStarted.current = true;
router.replace("/login");
}, [bearerToken, hasHydrated, router]);
if (!hasHydrated || !bearerToken) {
return (
<div
className="flex min-h-0 flex-1 items-center justify-center bg-white"
aria-hidden
>
<Loader2 className="size-8 animate-spin text-red-600" />
</div>
);
}
return children;
}

View File

@@ -42,6 +42,10 @@ export function PlayerLoginForm({
}: PlayerLoginFormProps) {
const [showPassword, setShowPassword] = useState(false);
const isDesktop = variant === "desktop";
const fieldIdPrefix = isDesktop ? "login-desktop" : "login-mobile";
const usernameId = `${fieldIdPrefix}-user`;
const passwordId = `${fieldIdPrefix}-pass`;
const captchaId = `${fieldIdPrefix}-captcha`;
const inputClass =
"h-10 rounded-xl border-gray-200 bg-white text-base shadow-none";
@@ -55,7 +59,7 @@ export function PlayerLoginForm({
onSubmit={onSubmit}
>
<div className="space-y-1.5">
<Label htmlFor="login-user" className="text-gray-700">
<Label htmlFor={usernameId} className="text-gray-700">
{t("login.username")}
</Label>
<div className="relative">
@@ -64,7 +68,7 @@ export function PlayerLoginForm({
aria-hidden
/>
<Input
id="login-user"
id={usernameId}
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
autoComplete="username"
@@ -76,7 +80,7 @@ export function PlayerLoginForm({
</div>
<div className="space-y-1.5">
<Label htmlFor="login-pass" className="text-gray-700">
<Label htmlFor={passwordId} className="text-gray-700">
{t("login.password")}
</Label>
<div className="relative">
@@ -85,7 +89,7 @@ export function PlayerLoginForm({
aria-hidden
/>
<Input
id="login-pass"
id={passwordId}
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
@@ -110,7 +114,7 @@ export function PlayerLoginForm({
</div>
<div className="space-y-1.5">
<Label htmlFor="login-captcha" className="text-gray-700">
<Label htmlFor={captchaId} className="text-gray-700">
{t("login.captcha")}
</Label>
<div className="flex items-stretch gap-3">
@@ -120,7 +124,7 @@ export function PlayerLoginForm({
aria-hidden
/>
<Input
id="login-captcha"
id={captchaId}
name="captcha"
autoComplete="off"
value={captchaCode}
@@ -164,4 +168,4 @@ export function PlayerLoginForm({
</Button>
</form>
);
}
}

View File

@@ -2,8 +2,8 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useI18nHydrated } from "@/components/i18n-hydration-provider";
import { useHydrationSafeEntryT } from "@/i18n/hydration-t";
import { toast } from "sonner";
@@ -32,7 +32,7 @@ function stripSearchParamFromBrowserUrl(name: string): void {
}
export function PlayerLoginScreen(): React.ReactElement {
const { t: tErrors } = useTranslation("entry");
const i18nHydrated = useI18nHydrated();
const t = useHydrationSafeEntryT();
const tRef = useRef(t);
useEffect(() => {
@@ -43,8 +43,9 @@ export function PlayerLoginScreen(): React.ReactElement {
const setBearerToken = usePlayerSessionStore((s) => s.setBearerToken);
const setProfile = usePlayerSessionStore((s) => s.setProfile);
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
const sessionExpiredHandled = useRef(false);
const sessionStatusHandled = useRef(false);
const passwordChangedHandled = useRef(false);
const captchaInitialized = useRef(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -71,29 +72,28 @@ export function PlayerLoginScreen(): React.ReactElement {
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
if (cancelled) {
return;
}
await loadCaptcha();
})();
return () => {
cancelled = true;
};
if (captchaInitialized.current) return;
captchaInitialized.current = true;
void loadCaptcha();
}, [loadCaptcha]);
useEffect(() => {
if (sessionExpiredHandled.current) return;
if (searchParams.get("session") !== "expired") return;
if (!i18nHydrated) return;
if (sessionStatusHandled.current) return;
const sessionStatus = searchParams.get("session");
if (sessionStatus !== "expired" && sessionStatus !== "replaced") return;
sessionExpiredHandled.current = true;
sessionStatusHandled.current = true;
clearBearerToken();
toast.error(tErrors("errors.sessionExpired"));
toast.error(
t(
sessionStatus === "replaced"
? "errors.sessionReplaced"
: "errors.sessionExpired",
),
);
stripSearchParamFromBrowserUrl("session");
}, [clearBearerToken, searchParams, tErrors]);
}, [clearBearerToken, i18nHydrated, searchParams, t]);
useEffect(() => {
if (passwordChangedHandled.current) return;
@@ -118,7 +118,7 @@ export function PlayerLoginScreen(): React.ReactElement {
}
if (!captchaCode.trim()) {
toast.error(t("login.captchaRequired"));
toast.error(t("login.captchaCodeRequired"));
return;
}

View File

@@ -21,6 +21,7 @@ import { DrawWinningCheckPanel } from "@/features/results/draw-winning-check-pan
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
import { TwentyThreeResultsGrid } from "@/features/results/twenty-three-results-grid";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { getPlayerBearerTokenPayload } from "@/lib/lottery-auth";
import { formatPlayerInstant } from "@/lib/player-datetime";
import { formatMinorAsCurrency } from "@/lib/money";
@@ -69,10 +70,8 @@ export function DrawResultDetailScreen({ drawNo }: DrawResultDetailScreenProps)
}
}, [activeCurrency, drawNo, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
useAsyncEffect(() => {
void load();
}, [load]);
useEffect(() => {

View File

@@ -31,6 +31,7 @@ import { PlayerListPagination } from "@/components/layout/player-list-pagination
import { JackpotResultsStrip } from "@/features/results/jackpot-results-strip";
import { useCurrencyCatalog } from "@/hooks/use-currency-catalog";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useIsMobile } from "@/hooks/use-mobile";
import { formatPlayerInstant } from "@/lib/player-datetime";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
@@ -100,10 +101,8 @@ export function DrawResultsListScreen() {
}
}, [activeCurrency, businessDate, t]);
useEffect(() => {
queueMicrotask(() => {
void fetchList(1, false);
});
useAsyncEffect(() => {
void fetchList(1, false);
}, [fetchList]);
useEffect(() => {

View File

@@ -5,7 +5,7 @@ import { BriefcaseBusiness, CheckCircle2, ChevronDown, Clock3, RefreshCw, XIcon
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { getTicketDrawMyMatch, getTicketItems } from "@/api/ticket-items";
import { getTicketItems } from "@/api/ticket-items";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -30,6 +30,27 @@ type WinningCheckResult = {
tickets: TicketItemListRow[];
};
function matchForTickets(
drawNo: string,
tickets: TicketItemListRow[],
): TicketDrawMyMatchPayload {
const winningTickets = tickets.filter(
(ticket) => ticket.win_amount > 0 || ticket.jackpot_win_amount > 0,
);
return {
draw_no: drawNo,
hit_numbers_4d: [],
total_win_minor: tickets.reduce((sum, ticket) => sum + ticket.win_amount, 0),
total_jackpot_win_minor: tickets.reduce(
(sum, ticket) => sum + ticket.jackpot_win_amount,
0,
),
winning_ticket_count: winningTickets.length,
has_bets: tickets.length > 0,
};
}
export type DrawWinningCheckPanelProps = {
drawNo: string;
businessDate?: string | null;
@@ -66,15 +87,15 @@ export function DrawWinningCheckPanel({
setLoading(true);
setError(null);
try {
const [match, tickets] = await Promise.all([
getTicketDrawMyMatch(drawNo),
getTicketItems({
draw_no: drawNo,
number: normalizedTicketNo,
per_page: 10,
page: 1,
}),
]);
const isTicketNumber = /^TK\d+$/i.test(normalizedTicketNo);
const ticketResult = await getTicketItems({
draw_no: drawNo,
ticket_no: isTicketNumber ? normalizedTicketNo.toUpperCase() : undefined,
number: isTicketNumber ? undefined : normalizedTicketNo,
per_page: 50,
page: 1,
});
const match = matchForTickets(drawNo, ticketResult.items);
const next = {
draw: {
draw_id: "",
@@ -88,7 +109,7 @@ export function DrawWinningCheckPanel({
result_items: [],
} satisfies DrawResultListItem,
match,
tickets: tickets.items,
tickets: ticketResult.items,
};
setResult(next);
setRecent((current) =>
@@ -331,7 +352,11 @@ function WinningResultDialog({
className="h-12 rounded-xl bg-[#07459f] text-base font-black text-white hover:bg-[#063b88]"
render={
<Link
href={`/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`}
href={
/^TK\d+$/i.test(query) && firstTicket
? `/orders/${encodeURIComponent(firstTicket.ticket_no)}`
: `/orders?draw_no=${encodeURIComponent(data.draw.draw_no)}&number=${encodeURIComponent(query)}`
}
/>
}
>
@@ -351,4 +376,4 @@ function WinningResultDialog({
</DialogContent>
</Dialog>
);
}
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOpenText, Loader2, TriangleAlert } from "lucide-react";
@@ -8,6 +8,16 @@ import { getPublicSettings } from "@/api";
import { PlayerPanel } from "@/components/layout/player-panel";
import { resolvePlayRulesHtml } from "@/lib/play-rules-html";
import { Card, CardContent } from "@/components/ui/card";
import { useAsyncEffect } from "@/hooks/use-async-effect";
const DEFAULT_RULE_SECTIONS = [
["dimensions", ["d4", "d3", "d2"]],
["bigSmall", ["big", "small"]],
["positions", ["d4", "d3", "d2"]],
["box", ["straight", "box", "ibox", "mbox", "roll", "half"]],
["attributes", ["headTail", "oddEven", "digitSize"]],
["wallet", ["rebate", "jackpot", "close", "soldOut"]],
] as const;
export function PlayRulesScreen() {
const { t, i18n } = useTranslation("player");
@@ -15,11 +25,13 @@ export function PlayRulesScreen() {
const [contentState, setContentState] = useState<"html" | "empty" | "error">("empty");
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadRules() {
useAsyncEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await getPublicSettings("frontend");
const html = resolvePlayRulesHtml(res.items, i18n.language);
if (cancelled) return;
if (html) {
setHtmlContent(html);
setContentState("html");
@@ -28,13 +40,17 @@ export function PlayRulesScreen() {
setContentState("empty");
}
} catch {
if (cancelled) return;
setHtmlContent(null);
setContentState("error");
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
}
void loadRules();
})();
return () => {
cancelled = true;
};
}, [i18n.language, t]);
return (
@@ -50,26 +66,62 @@ export function PlayRulesScreen() {
<div className="flex min-h-[18rem] items-center justify-center">
<Loader2 className="size-6 animate-spin text-slate-400" />
</div>
) : contentState !== "html" ? (
<div className="flex min-h-[18rem] flex-col items-center justify-center px-4 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-[#eef4ff] text-[#2d63e2]">
{contentState === "error" ? (
<TriangleAlert className="size-5" aria-hidden />
) : (
<BookOpenText className="size-5" aria-hidden />
)}
</span>
<p className="mt-3 text-sm font-bold text-slate-700">
{contentState === "error"
? t("rules.loadFailed", { defaultValue: "规则加载失败" })
: t("rules.empty", { defaultValue: "暂无玩法规则说明" })}
</p>
</div>
) : (
) : contentState === "html" ? (
<div
className="prose prose-sm max-w-none text-slate-700 lg:prose-base lg:leading-7"
dangerouslySetInnerHTML={{ __html: htmlContent || "" }}
/>
) : (
<div className="space-y-5">
{contentState === "error" ? (
<div className="flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-800">
<TriangleAlert className="size-4 shrink-0" aria-hidden />
{t("rules.loadFailed", { defaultValue: "规则加载失败" })}
</div>
) : null}
<div className="rounded-2xl border border-[#dbe7fb] bg-[#f6f9ff] p-4 lg:p-5">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white text-[#2d63e2] shadow-sm">
<BookOpenText className="size-5" aria-hidden />
</span>
<div>
<h2 className="font-black text-[#0b3f96]">
{t("rules.quick.title")}
</h2>
<p className="mt-1 text-sm leading-6 text-slate-600">
{t("rules.quick.description")}
</p>
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{DEFAULT_RULE_SECTIONS.map(([section, items]) => (
<section
key={section}
className="rounded-2xl border border-[#e5edf8] bg-white p-4 shadow-[0_6px_20px_rgba(15,23,42,0.04)] lg:p-5"
>
<h2 className="font-black text-[#0b3f96]">
{t(`rules.sections.${section}.title`)}
</h2>
<ul className="mt-3 space-y-2.5 text-sm leading-6 text-slate-600">
{items.map((item) => (
<li key={item} className="flex gap-2">
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-[#e5002c]" aria-hidden />
<span>{t(`rules.sections.${section}.${item}`)}</span>
</li>
))}
</ul>
</section>
))}
</div>
<div className="space-y-1 rounded-2xl border border-[#e5edf8] bg-[#f8fbff] p-4 text-xs leading-5 text-slate-500 lg:p-5">
<p>{t("rules.footer.config")}</p>
<p>{t("rules.footer.phaseTwo")}</p>
</div>
</div>
)}
</CardContent>
</Card>

View File

@@ -1,5 +1,7 @@
"use client";
import { ChevronDown } from "lucide-react";
import Link from "next/link";
import { useMemo, type Ref } from "react";
import { useTranslation } from "react-i18next";
@@ -27,7 +29,6 @@ export const CREDIT_FLOW_FILTERS: { value: string; labelKey: string }[] = [
{ value: "", labelKey: "wallet.creditFlow.all" },
{ value: "bet", labelKey: "wallet.creditFlow.bet" },
{ value: "game_settlement", labelKey: "wallet.creditFlow.game_settlement" },
{ value: "rebate", labelKey: "wallet.creditFlow.rebate" },
{ value: "bill_settlement", labelKey: "wallet.creditFlow.bill_settlement" },
];
@@ -40,8 +41,8 @@ const FLOW_LABEL_FALLBACKS: Record<string, string> = {
"wallet.flow.refund": "退款",
"wallet.flow.reversal": "冲正",
"wallet.creditFlow.all": "全部",
"wallet.creditFlow.bet": "下注冻结",
"wallet.creditFlow.game_settlement": "开奖结",
"wallet.creditFlow.bet": "下注",
"wallet.creditFlow.game_settlement": "开奖结",
"wallet.creditFlow.rebate": "账期回水",
"wallet.creditFlow.credit_release": "释额",
"wallet.creditFlow.bill_settlement": "账期收付",
@@ -135,7 +136,7 @@ export function WalletLogsBlock({
const resolvedTitle =
title
?? (creditMode
? t("wallet.creditFlowsTitle", { defaultValue: "信用流水" })
? t("wallet.creditFlowsTitle", { defaultValue: "额度与结算记录" })
: t("wallet.flowsTitle", { defaultValue: "钱包流水" }));
const filters = useMemo(() => {
const source = creditMode ? CREDIT_FLOW_FILTERS : WALLET_FLOW_FILTERS;
@@ -197,7 +198,10 @@ export function WalletLogsBlock({
) : (
<>
<div className="lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<div className="hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3">
<div className={creditMode
? "hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3"
: "hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3"}
>
<p className="player-pc-table-head font-bold text-[#59739f]">
{t("wallet.logType", { defaultValue: "类型" })}
</p>
@@ -215,17 +219,25 @@ export function WalletLogsBlock({
? t("wallet.creditAvailableAfter")
: t("wallet.balanceAfter")}
</p>
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
{t("orders.status")}
</p>
{!creditMode ? (
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
{t("orders.status")}
</p>
) : null}
</div>
<ul className="space-y-2 lg:space-y-0">
{logs.items.map((row) => (
{logs.items.map((row) => creditMode ? (
<CreditActivityRow
key={row.log_id}
item={row}
currency={currency}
settlementTimeZone={settlementTimeZone}
/>
) : (
<LogRow
key={row.log_id}
item={row}
currency={currency}
creditMode={creditMode}
settlementTimeZone={settlementTimeZone}
/>
))}
@@ -272,6 +284,158 @@ export function WalletLogsBlock({
);
}
function creditActivityLabel(
item: WalletLogItem,
t: (key: string, options?: { defaultValue?: string }) => string,
): string {
const labels: Record<string, { key: string; fallback: string }> = {
bet_pending: { key: "wallet.activity.bet", fallback: "下注" },
settled_loss: { key: "wallet.activity.settledLoss", fallback: "未中奖" },
settled_win: { key: "wallet.activity.settledWin", fallback: "中奖" },
settled_even: { key: "wallet.activity.result", fallback: "开奖结果" },
bet_refund: { key: "wallet.activity.refunded", fallback: "已退款" },
period_paid: { key: "wallet.activity.periodPaid", fallback: "已付款" },
period_received: { key: "wallet.activity.periodReceived", fallback: "已收款" },
};
const resolved = labels[item.biz_type];
if (!resolved) {
return t("wallet.activity.result", { defaultValue: "开奖结果" });
}
return t(resolved.key, { defaultValue: resolved.fallback });
}
function CreditActivityRow({
item,
currency,
settlementTimeZone,
}: {
item: WalletLogItem;
currency: string;
settlementTimeZone?: string;
}) {
const { t } = useTranslation("player");
const ccy = item.currency_code || currency;
const amount = Number(item.net_amount ?? item.amount ?? 0);
const amountTone = amount > 0
? "text-emerald-700"
: amount < 0
? "text-[#e5002c]"
: "text-[#32518d]";
const amountLabel = amount === 0
? formatMinorAsCurrency(0, ccy)
: `${amount > 0 ? "+" : ""}${formatMinorAsCurrency(Math.abs(amount), ccy)}`;
const timeLabel = settlementTimeZone
? formatLotteryInstantInTimeZone(item.created_at, settlementTimeZone)
: formatPlayerInstant(item.created_at);
const balanceLabel = item.balance_after == null
? "—"
: formatMinorAsCurrency(item.balance_after, ccy);
const refLabel = item.draw_no
? `${item.draw_no}${item.order_no ? ` · ${item.order_no}` : ""}`
: item.order_no ?? (item.settlement_bill_id ? `#${item.settlement_bill_id}` : "—");
const detailHref = item.ticket_no
? `/orders/${encodeURIComponent(item.ticket_no)}`
: item.draw_no
? `/orders?draw_no=${encodeURIComponent(item.draw_no)}`
: null;
const showStatus = item.activity_status === "pending" || item.activity_status === "reversed";
const statusLabel = item.activity_status === "reversed"
? t("wallet.activity.reversed", { defaultValue: "已冲正" })
: item.activity_kind === "bet"
? t("wallet.activity.awaitingDraw", { defaultValue: "待开奖" })
: t("wallet.activity.processing", { defaultValue: "处理中" });
return (
<li className="rounded-2xl border border-[#e1eaf6] bg-white px-3 py-3 text-sm shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(10rem,1fr)_minmax(8rem,0.8fr)_minmax(8rem,0.8fr)] lg:items-center lg:gap-3 lg:rounded-none lg:border-x-0 lg:border-t-0 lg:border-b-[#edf2f8] lg:px-5 lg:py-4 lg:shadow-none lg:last:border-b-0 lg:hover:bg-[#fbfdff]">
<div className="flex items-start justify-between gap-3 lg:contents">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-base font-black leading-tight text-[#101a33] lg:text-sm">
{creditActivityLabel(item, t)}
</p>
{showStatus ? (
<span className={item.activity_status === "reversed"
? "inline-flex rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-black text-slate-600"
: "inline-flex rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-black text-blue-700"}
>
{statusLabel}
</span>
) : null}
</div>
<p className="mt-1.5 text-xs font-medium text-slate-500 lg:hidden">{timeLabel}</p>
<p className="mt-1 truncate font-mono text-[11px] text-slate-400 lg:hidden">{refLabel}</p>
</div>
<p className={`shrink-0 text-right text-lg font-black tabular-nums lg:hidden ${amountTone}`}>
{amountLabel}
</p>
</div>
<p className="hidden text-sm text-slate-600 lg:block">{timeLabel}</p>
<div className="hidden min-w-0 lg:block">
{detailHref ? (
<Link className="block truncate font-mono text-xs text-[#32518d] hover:underline" href={detailHref}>
{refLabel}
</Link>
) : (
<p className="truncate font-mono text-xs text-slate-400">{refLabel}</p>
)}
</div>
<p className={`hidden text-right text-sm font-black tabular-nums lg:block ${amountTone}`}>
{amountLabel}
</p>
<p className="hidden text-right font-mono text-sm font-black tabular-nums text-[#32518d] lg:block">
{balanceLabel}
</p>
<details className="group mt-3 rounded-xl bg-[#f8fbff] lg:col-span-5 lg:mt-0">
<summary className="flex min-h-9 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-bold text-[#32518d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2d63e2] [&::-webkit-details-marker]:hidden">
{t("wallet.activity.details", { defaultValue: "查看明细" })}
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
</summary>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-[#e1eaf6] px-3 py-3 text-xs lg:grid-cols-4">
{Number(item.stake_amount ?? 0) > 0 ? (
<div>
<p className="font-semibold text-slate-500">{t("wallet.activity.stake", { defaultValue: "下注金额" })}</p>
<p className="mt-1 font-mono font-black text-[#101a33]">{formatMinorAsCurrency(item.stake_amount ?? 0, ccy)}</p>
</div>
) : null}
{Number(item.win_amount ?? 0) > 0 ? (
<div>
<p className="font-semibold text-slate-500">{t("wallet.activity.win", { defaultValue: "中奖金额" })}</p>
<p className="mt-1 font-mono font-black text-emerald-700">+{formatMinorAsCurrency(item.win_amount ?? 0, ccy)}</p>
</div>
) : null}
{Number(item.rebate_amount ?? 0) > 0 ? (
<div>
<p className="font-semibold text-slate-500">{t("wallet.activity.rebate", { defaultValue: "回水" })}</p>
<p className="mt-1 font-mono font-black text-emerald-700">+{formatMinorAsCurrency(item.rebate_amount ?? 0, ccy)}</p>
</div>
) : null}
<div>
<p className="font-semibold text-slate-500">{t("wallet.creditAvailableAfter", { defaultValue: "变更后可用额度" })}</p>
<p className="mt-1 font-mono font-black text-[#32518d]">{balanceLabel}</p>
</div>
{Number(item.ticket_count ?? 0) > 0 ? (
<div>
<p className="font-semibold text-slate-500">{t("wallet.activity.ticketCount", { defaultValue: "注单数量" })}</p>
<p className="mt-1 font-black text-[#101a33]">{t("wallet.activity.ticketCountValue", { defaultValue: "{{count}}项", count: item.ticket_count })}</p>
</div>
) : null}
{detailHref ? (
<div className="col-span-2 flex items-end justify-end lg:col-span-1">
<Link className="font-bold text-[#2d63e2] hover:underline" href={detailHref}>
{t("wallet.activity.viewOrder", { defaultValue: "查看注单" })}
</Link>
</div>
) : null}
</div>
</details>
</li>
);
}
export function LogRow({
item,
currency,

View File

@@ -1,6 +1,6 @@
"use client";
import { ArrowDownLeft, ArrowUpRight, Wallet } from "lucide-react";
import { ArrowDownLeft, ArrowUpRight, ChevronDown, Wallet } from "lucide-react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -18,8 +18,9 @@ import { PlayerMoneyDisplay } from "@/components/player-money-display";
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency";
import { useIsMobile } from "@/hooks/use-mobile";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { isCreditFundingPlayer } from "@/lib/player-funding-mode";
import { formatMinorAsCurrency } from "@/lib/money";
import { formatMinorAmount, 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";
@@ -101,91 +102,86 @@ function CreditSettlementBillsBlock({
const netPending = Number(summary?.net_pending ?? 0);
const pendingCount = Number(summary?.pending_count ?? 0);
const netDirection = netPending >= 0 ? "receivable" : "payable";
const hasBills = (data?.items.length ?? 0) > 0;
return (
<section className="flex h-full flex-col rounded-2xl border border-[#dce7f7] bg-white px-3 py-3 shadow-[0_10px_28px_rgba(15,23,42,0.06)] lg:px-4 lg:py-4">
<div className="flex shrink-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-black text-[#0b3f96] lg:text-base">
{t("wallet.settlementTitle", { defaultValue: "我的账期账单" })}
{t("wallet.settlementTitle", { defaultValue: "账期结算" })}
</h2>
<p className="mt-1 text-xs text-slate-500 lg:line-clamp-2">
{t("wallet.settlementHint", { defaultValue: "只显示未结清账单,中奖超出授信的部分在这里结算。" })}
{t("wallet.settlementHint", { defaultValue: "查看当前需要收取或支付的金额。" })}
</p>
</div>
<span className="shrink-0 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 shrink-0 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 className="col-span-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: "净待付" })}
{pendingCount > 0 ? (
<span className="shrink-0 rounded-full bg-[#f1f6ff] px-2.5 py-1 text-xs font-black text-[#32518d]">
{t("wallet.settlementPendingCount", { defaultValue: "{{count}}笔", count: pendingCount })}
</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>
) : null}
</div>
{data === null ? (
<Skeleton className="mt-3 min-h-20 w-full flex-1 rounded-xl" />
) : data.items.length === 0 ? (
<p className="mt-3 flex min-h-20 flex-1 items-center justify-center rounded-xl border border-dashed border-[#dce7f7] px-3 text-center text-sm text-slate-500">
{t("wallet.noSettlementBills", { defaultValue: "暂无待结算账单" })}
) : !hasBills ? (
<p className="mt-3 flex min-h-24 flex-1 items-center justify-center rounded-xl bg-emerald-50 px-3 text-center text-sm font-bold text-emerald-700">
{t("wallet.settlementClear", { defaultValue: "当前无需结算" })}
</p>
) : (
<ul className="mt-3 min-h-0 max-h-[16rem] flex-1 space-y-2 overflow-y-auto lg:max-h-none">
{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 className={netDirection === "receivable"
? "mt-3 rounded-xl bg-emerald-50 px-3 py-3 text-emerald-700"
: "mt-3 rounded-xl bg-red-50 px-3 py-3 text-red-700"}
>
<p className="text-xs font-bold">
{netDirection === "receivable"
? t("wallet.netReceivable", { defaultValue: "净待收" })
: t("wallet.netPayable", { defaultValue: "净待付" })}
</p>
<p className="mt-1 font-mono text-lg font-black tabular-nums">
{formatMinorAsCurrency(Math.abs(netPending), currency)}
</p>
</div>
<details className="group mt-3 rounded-xl border border-[#e1eaf6] bg-[#f8fbff]">
<summary className="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold text-[#32518d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2d63e2] [&::-webkit-details-marker]:hidden">
{t("wallet.settlementDetails", { defaultValue: "查看账期明细" })}
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
</summary>
<ul className="max-h-[16rem] space-y-2 overflow-y-auto border-t border-[#e1eaf6] p-2">
{data.items.map((item) => {
const receivable = item.direction === "receivable";
return (
<li key={item.id} className="rounded-lg bg-white 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" aria-hidden /> : <ArrowUpRight className="size-3.5" aria-hidden />}
</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>
</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>
</li>
);
})}
</ul>
</details>
</>
)}
</section>
);
@@ -267,7 +263,7 @@ export function WalletScreen() {
return nextLogs;
}, [currency, filter]);
useEffect(() => {
useAsyncEffect(() => {
let cancelled = false;
void (async () => {
@@ -307,7 +303,7 @@ export function WalletScreen() {
return () => {
cancelled = true;
};
}, [currency, t]); // eslint-disable-line react-hooks/exhaustive-deps -- 币种切换整页刷新;流水筛选见下方 effect
}, [currency, t]);
useEffect(() => {
if (!filterInitializedRef.current) {
@@ -395,10 +391,12 @@ export function WalletScreen() {
const displayMinor = isCreditPlayer
? Number(balance?.available_balance ?? 0)
: Number(balance?.balance ?? 0);
const creditLimitMinor = Number(balance?.credit_limit ?? 0);
const creditInUseMinor = Math.max(0, creditLimitMinor - displayMinor);
const panelTitle = !fundingModeKnown
? t("wallet.loadingTitle", { defaultValue: "账户资金" })
: isCreditPlayer
? t("wallet.creditTitle", { defaultValue: "信用" })
? t("wallet.creditTitle", { defaultValue: "额度" })
: t("wallet.title");
const loadMore = useCallback(() => {
@@ -470,6 +468,7 @@ export function WalletScreen() {
src="/entry/image5.png"
alt=""
fill
loading="eager"
className="pointer-events-none object-cover object-center"
aria-hidden
/>
@@ -535,6 +534,7 @@ export function WalletScreen() {
src="/entry/image5.png"
alt=""
fill
loading="eager"
className="pointer-events-none object-cover object-center"
aria-hidden
/>
@@ -545,7 +545,7 @@ export function WalletScreen() {
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white/90 lg:text-base">
{fundingModeKnown
? t("wallet.creditAvailable", { defaultValue: "可用信用" })
? t("wallet.creditAvailable", { defaultValue: "可用额度" })
: t("wallet.loadingBalance", { defaultValue: "正在加载" })}
</p>
{loading || !fundingModeKnown ? (
@@ -554,40 +554,43 @@ export function WalletScreen() {
<PlayerMoneyDisplay
amountMinor={displayMinor}
currency={currency}
showCurrencyCode={false}
className="mt-1 text-white lg:text-[1.75rem]"
/>
)}
<p className="mt-2 text-xs text-white/75 lg:text-sm">
{!fundingModeKnown
? t("wallet.loadingAccountMode", { defaultValue: "正在确认资金模式…" })
: t("wallet.creditSummary", {
defaultValue: "授信 {{limit}} · 已用 {{used}}",
limit: formatMinorAsCurrency(balance?.credit_limit ?? 0, currency),
used: formatMinorAsCurrency(balance?.used_credit ?? 0, currency),
})}
: t("wallet.creditAvailableHint", { defaultValue: "当前可用于下注" })}
</p>
</div>
</div>
{fundingModeKnown ? (
<div className="relative mt-4 grid shrink-0 grid-cols-2 gap-2 lg:mt-auto lg:gap-3">
<div className="rounded-xl bg-white/15 px-3 py-2.5 backdrop-blur-[2px]">
<p className="text-[11px] font-bold uppercase tracking-wide text-white/75">
{t("wallet.creditLimit", { defaultValue: "授信额度" })}
</p>
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
{formatMinorAsCurrency(balance?.credit_limit ?? 0, currency)}
</p>
<details className="group relative mt-4 shrink-0 rounded-xl bg-white/15 backdrop-blur-[2px] lg:mt-auto">
<summary className="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white [&::-webkit-details-marker]:hidden">
{t("wallet.creditDetails", { defaultValue: "额度详情" })}
<ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />
</summary>
<div className="grid grid-cols-2 gap-2 border-t border-white/15 p-2">
<div className="rounded-lg bg-white/10 px-3 py-2">
<p className="text-[11px] font-bold text-white/75">
{t("wallet.creditLimit", { defaultValue: "总额度" })}
</p>
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
{formatMinorAmount(creditLimitMinor)}
</p>
</div>
<div className="rounded-lg bg-white/10 px-3 py-2">
<p className="text-[11px] font-bold text-white/75">
{t("wallet.usedCredit", { defaultValue: "使用中" })}
</p>
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
{formatMinorAmount(creditInUseMinor)}
</p>
</div>
</div>
<div className="rounded-xl bg-white/15 px-3 py-2.5 backdrop-blur-[2px]">
<p className="text-[11px] font-bold uppercase tracking-wide text-white/75">
{t("wallet.usedCredit", { defaultValue: "已用信用" })}
</p>
<p className="mt-1 font-mono text-sm font-black tabular-nums text-white">
{formatMinorAsCurrency(balance?.used_credit ?? 0, currency)}
</p>
</div>
</div>
</details>
) : null}
</section>