feat: 增强钱包 API 与玩家会话管理
- 新增钱包 API 函数:getWalletLogs(获取钱包日志)、postWalletTransferIn(充值)及 postWalletTransferOut(提现) - 更新钱包相关类型定义,提升类型安全性 - 改进玩家会话管理:若当前无玩家资料,则自动拉取玩家信息 - 增强入口网关对过期会话的错误处理能力 - 更新 UI 组件,以适配新的结构与功能
This commit is contained in:
@@ -1,4 +1,16 @@
|
||||
export { API_V1_PREFIX } from "@/api/paths";
|
||||
export { getHealth } from "@/api/health";
|
||||
export { getPlayerPing, getPlayerMe } from "@/api/player";
|
||||
export { getWalletBalance, type GetWalletBalanceParams } from "@/api/wallet";
|
||||
export {
|
||||
getWalletBalance,
|
||||
getWalletLogs,
|
||||
postWalletTransferIn,
|
||||
postWalletTransferOut,
|
||||
type GetWalletBalanceParams,
|
||||
type GetWalletLogsParams,
|
||||
type WalletLogItem,
|
||||
type WalletLogsData,
|
||||
type WalletPendingTransfer,
|
||||
type WalletTransferBody,
|
||||
type WalletTransferResultData,
|
||||
} from "@/api/wallet";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { lotteryRequest } from "@/lib/lottery-http";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
import { API_V1_PREFIX } from "@/api/paths";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
import type {
|
||||
WalletTransferBody,
|
||||
WalletTransferResultData,
|
||||
} from "@/types/api/wallet-transfer";
|
||||
import type { GetWalletLogsParams, WalletLogsData } from "@/types/api/wallet-logs";
|
||||
|
||||
export type GetWalletBalanceParams = {
|
||||
/** Query `currency`,不传则用玩家默认币种 */
|
||||
@@ -17,3 +21,41 @@ export function getWalletBalance(
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** `GET /api/v1/wallet/logs`(需玩家 Token) */
|
||||
export function getWalletLogs(
|
||||
params?: GetWalletLogsParams,
|
||||
): Promise<WalletLogsData> {
|
||||
return lotteryRequest.get<WalletLogsData>(
|
||||
`${API_V1_PREFIX}/wallet/logs`,
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** `POST /api/v1/wallet/transfer-in`(主站 → 彩票) */
|
||||
export function postWalletTransferIn(
|
||||
body: WalletTransferBody,
|
||||
): Promise<WalletTransferResultData> {
|
||||
return lotteryRequest.post<WalletTransferResultData>(
|
||||
`${API_V1_PREFIX}/wallet/transfer-in`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/** `POST /api/v1/wallet/transfer-out`(彩票 → 主站) */
|
||||
export function postWalletTransferOut(
|
||||
body: WalletTransferBody,
|
||||
): Promise<WalletTransferResultData> {
|
||||
return lotteryRequest.post<WalletTransferResultData>(
|
||||
`${API_V1_PREFIX}/wallet/transfer-out`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export type { WalletTransferBody, WalletTransferResultData } from "@/types/api/wallet-transfer";
|
||||
export type {
|
||||
GetWalletLogsParams,
|
||||
WalletLogItem,
|
||||
WalletLogsData,
|
||||
WalletPendingTransfer,
|
||||
} from "@/types/api/wallet-logs";
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { HallScreen } from "@/features/hall/hall-screen";
|
||||
|
||||
/** 下注大厅占位(§4.2 再接表格与期号) */
|
||||
export default function LotteryHallPlaceholderPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>下注大厅</CardTitle>
|
||||
<CardDescription>
|
||||
路由已打通;玩法表格、期号与余额将在此页迭代。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
从入口页授权成功后可刷新本页;鉴权头会从会话存储恢复。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
export default function LotteryHallPage() {
|
||||
return <HallScreen />;
|
||||
}
|
||||
|
||||
5
src/app/(player)/(main)/wallet/logs/page.tsx
Normal file
5
src/app/(player)/(main)/wallet/logs/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WalletLogsScreen } from "@/features/wallet/wallet-logs-screen";
|
||||
|
||||
export default function WalletLogsPage() {
|
||||
return <WalletLogsScreen />;
|
||||
}
|
||||
6
src/app/(player)/(main)/wallet/page.tsx
Normal file
6
src/app/(player)/(main)/wallet/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { WalletScreen } from "@/features/wallet/wallet-screen";
|
||||
|
||||
/** 界面文档 §4.9 彩票钱包:余额、转入/转出、流水 */
|
||||
export default function WalletPage() {
|
||||
return <WalletScreen />;
|
||||
}
|
||||
5
src/app/(player)/(main)/wallet/transfer-in/page.tsx
Normal file
5
src/app/(player)/(main)/wallet/transfer-in/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TransferInScreen } from "@/features/wallet/transfer-in-screen";
|
||||
|
||||
export default function WalletTransferInPage() {
|
||||
return <TransferInScreen />;
|
||||
}
|
||||
5
src/app/(player)/(main)/wallet/transfer-out/page.tsx
Normal file
5
src/app/(player)/(main)/wallet/transfer-out/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TransferOutScreen } from "@/features/wallet/transfer-out-screen";
|
||||
|
||||
export default function WalletTransferOutPage() {
|
||||
return <TransferOutScreen />;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PlayerSessionBar } from "@/features/player/player-session-bar";
|
||||
|
||||
type PlayerAppShellProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -12,8 +15,27 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-background text-foreground">
|
||||
<header className="sticky top-0 z-40 shrink-0 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto flex h-12 max-w-lg items-center px-4">
|
||||
<span className="text-sm font-semibold tracking-tight">Lottery</span>
|
||||
<div className="mx-auto flex h-12 max-w-lg items-center justify-between gap-2 px-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-semibold tracking-tight">
|
||||
Lottery
|
||||
</span>
|
||||
<PlayerSessionBar className="min-w-0 border-l border-border pl-2" />
|
||||
</div>
|
||||
<nav className="flex shrink-0 items-center gap-2.5 text-xs font-medium sm:gap-3">
|
||||
<Link
|
||||
href="/hall"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
大厅
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
钱包
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto flex w-full max-w-lg flex-1 flex-col gap-4 px-4 py-4">
|
||||
|
||||
34
src/features/hall/hall-screen.tsx
Normal file
34
src/features/hall/hall-screen.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
import { HallWalletStrip } from "@/features/hall/hall-wallet-strip";
|
||||
|
||||
/**
|
||||
* 下注大厅:顶部钱包条对齐高保真稿;以下为期号/表格占位。
|
||||
*/
|
||||
export function HallScreen() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<HallWalletStrip />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">下注大厅</CardTitle>
|
||||
<CardDescription>
|
||||
Issue No.、倒计时、2D/3D/4D 表格与 Submit Bet 将按界面文档 §4.2 接续开发。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
封盘态、WebSocket 降级轮询等与 PRD §2 一致时再接入。
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/features/hall/hall-wallet-strip.tsx
Normal file
118
src/features/hall/hall-wallet-strip.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
TransferInDialog,
|
||||
TransferOutDialog,
|
||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
/**
|
||||
* 高保真稿:大厅顶部红卡 + Transfer In(蓝)/ Transfer Out(白底红边),§4.2
|
||||
*/
|
||||
export function HallWalletStrip() {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
|
||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const currency = useMemo(
|
||||
() =>
|
||||
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
|
||||
[balance?.currency_code, profile?.default_currency],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const b = await getWalletBalance();
|
||||
setBalance(b);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let c = false;
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
if (!c) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
c = true;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const lotteryMinor = Number(balance?.balance ?? 0);
|
||||
const availableMinor = Number(balance?.available_balance ?? 0);
|
||||
|
||||
return (
|
||||
<section className="space-y-2" aria-label="Wallet balance">
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-2xl bg-gradient-to-br from-[#dc2626] to-[#991b1b] px-4 py-3.5 text-white shadow-md",
|
||||
"ring-1 ring-black/10",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-white/15">
|
||||
<Wallet className="size-6 text-white" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
|
||||
Wallet Balance
|
||||
</p>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-8 w-40 rounded-md bg-white/20" />
|
||||
) : (
|
||||
<p className="font-heading text-2xl font-bold tabular-nums tracking-tight">
|
||||
{formatMinorAsCurrency(lotteryMinor, currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "secondary",
|
||||
size: "sm",
|
||||
}),
|
||||
"shrink-0 border-0 bg-white/15 text-xs text-white hover:bg-white/25",
|
||||
)}
|
||||
>
|
||||
明细
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TransferInDialog
|
||||
idPrefix="hall-"
|
||||
triggerVariant="hall"
|
||||
triggerLabel="Transfer In"
|
||||
triggerClassName="w-full min-w-0"
|
||||
currency={currency}
|
||||
lotteryMinor={lotteryMinor}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
<TransferOutDialog
|
||||
idPrefix="hall-"
|
||||
triggerVariant="hall"
|
||||
triggerLabel="Transfer Out"
|
||||
triggerClassName="w-full min-w-0"
|
||||
currency={currency}
|
||||
availableMinor={availableMinor}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,12 @@ export function EntryGate(): ReactNode {
|
||||
|
||||
if (!token) {
|
||||
setPhase("error");
|
||||
setErrorMessage("缺少登录凭证,请从主站重新进入彩票系统。");
|
||||
const sessionFlag = searchParams.get("session");
|
||||
setErrorMessage(
|
||||
sessionFlag === "expired"
|
||||
? "登录已失效,请从主站重新进入彩票系统。"
|
||||
: "缺少登录凭证,请从主站重新进入彩票系统。",
|
||||
);
|
||||
updateStep("token", "pending");
|
||||
applyProgress(0);
|
||||
return;
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getPlayerMe } from "@/api/player";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
/** 从 sessionStorage 恢复 Bearer,避免 `/hall` 等子路由刷新后丢失鉴权头 */
|
||||
/**
|
||||
* 从 sessionStorage 恢复 Bearer,避免 `/hall` 等子路由刷新后丢失鉴权头;
|
||||
* 若有 Token 无 `profile`,补拉 `GET /player/me` 供顶栏展示。
|
||||
*/
|
||||
export function HydratePlayerAuth(): null {
|
||||
const restoreBearerToken = usePlayerSessionStore(
|
||||
(state) => state.restoreBearerToken,
|
||||
);
|
||||
const setProfile = usePlayerSessionStore((state) => state.setProfile);
|
||||
|
||||
useEffect(() => {
|
||||
restoreBearerToken();
|
||||
}, [restoreBearerToken]);
|
||||
const token = restoreBearerToken();
|
||||
if (!token) return;
|
||||
if (usePlayerSessionStore.getState().profile !== null) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const me = await getPlayerMe();
|
||||
setProfile(me);
|
||||
} catch {
|
||||
/* 401 由 lottery-http 拦截跳转 */
|
||||
}
|
||||
})();
|
||||
}, [restoreBearerToken, setProfile]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
47
src/features/player/player-session-bar.tsx
Normal file
47
src/features/player/player-session-bar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { UserRound } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
|
||||
/**
|
||||
* 顶栏:当前玩家称呼 + 默认币种(依赖入口或 Hydrate 拉取的 profile)。
|
||||
*/
|
||||
export function PlayerSessionBar({ className }: { className?: string }) {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
|
||||
const label =
|
||||
profile?.nickname?.trim() ||
|
||||
profile?.username?.trim() ||
|
||||
(profile?.id != null ? `玩家 #${profile.id}` : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-[55%] items-center gap-1.5 sm:max-w-[60%]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<UserRound className="size-3.5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{label ?? "…"}
|
||||
</p>
|
||||
{profile?.default_currency ? (
|
||||
<p className="truncate text-[10px] text-muted-foreground">
|
||||
{profile.default_currency.toUpperCase()}
|
||||
{profile.locale ? (
|
||||
<span className="text-muted-foreground/80">
|
||||
{" "}
|
||||
· {profile.locale}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/features/wallet/transfer-in-screen.tsx
Normal file
65
src/features/wallet/transfer-in-screen.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { TransferInPage } from "@/features/wallet/wallet-transfer-forms";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
/** 独立路由 `/wallet/transfer-in` */
|
||||
export function TransferInScreen() {
|
||||
const router = useRouter();
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const currency = useMemo(
|
||||
() =>
|
||||
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
|
||||
[balance?.currency_code, profile?.default_currency],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const b = await getWalletBalance();
|
||||
setBalance(b);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let c = false;
|
||||
void (async () => {
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
if (!c) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
c = true;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const onSuccess = useCallback(async () => {
|
||||
await load();
|
||||
router.push("/wallet");
|
||||
}, [load, router]);
|
||||
|
||||
if (loading && !balance) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TransferInPage
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
src/features/wallet/transfer-out-screen.tsx
Normal file
65
src/features/wallet/transfer-out-screen.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getWalletBalance } from "@/api/wallet";
|
||||
import { TransferOutPage } from "@/features/wallet/wallet-transfer-forms";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
/** 独立路由 `/wallet/transfer-out` */
|
||||
export function TransferOutScreen() {
|
||||
const router = useRouter();
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const currency = useMemo(
|
||||
() =>
|
||||
(balance?.currency_code ?? profile?.default_currency ?? "NPR").toUpperCase(),
|
||||
[balance?.currency_code, profile?.default_currency],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const b = await getWalletBalance();
|
||||
setBalance(b);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let c = false;
|
||||
void (async () => {
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
if (!c) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
c = true;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const onSuccess = useCallback(async () => {
|
||||
await load();
|
||||
router.push("/wallet");
|
||||
}, [load, router]);
|
||||
|
||||
if (loading && !balance) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TransferOutPage
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
}
|
||||
170
src/features/wallet/wallet-logs-block.tsx
Normal file
170
src/features/wallet/wallet-logs-block.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import type { WalletLogItem, WalletLogsData } from "@/types/api/wallet-logs";
|
||||
|
||||
/** 与 §4.9 筛选一致;接口 `type` 查询参数 */
|
||||
export const WALLET_FLOW_FILTERS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "transfer_in", label: "转入" },
|
||||
{ value: "transfer_out", label: "转出" },
|
||||
{ value: "bet", label: "下注扣款" },
|
||||
{ value: "prize", label: "派彩" },
|
||||
{ value: "refund", label: "退本/冲正" },
|
||||
];
|
||||
|
||||
export function logTypeLabel(t: string): string {
|
||||
const map: Record<string, string> = {
|
||||
transfer_in: "转入",
|
||||
transfer_out: "转出",
|
||||
refund: "退本/冲正",
|
||||
bet: "下注扣款",
|
||||
prize: "派彩",
|
||||
};
|
||||
return map[t] ?? t;
|
||||
}
|
||||
|
||||
function txnStatusLabel(status: string): string {
|
||||
if (status === "posted") return "成功";
|
||||
if (status === "pending_reconcile") return "待对账";
|
||||
return status;
|
||||
}
|
||||
|
||||
type WalletLogsBlockProps = {
|
||||
logs: WalletLogsData | null;
|
||||
logsLoading: boolean;
|
||||
filter: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
currency: string;
|
||||
/** 独立流水页可隐藏标题或改文案 */
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/** 待对账 + 类型筛选 + 列表(供钱包主页与 `/wallet/logs` 共用) */
|
||||
export function WalletLogsBlock({
|
||||
logs,
|
||||
logsLoading,
|
||||
filter,
|
||||
onFilterChange,
|
||||
currency,
|
||||
title = "资金流水",
|
||||
}: WalletLogsBlockProps) {
|
||||
return (
|
||||
<>
|
||||
{logs && logs.pending_reconcile.length > 0 ? (
|
||||
<Card className="border-[#faad14]/50 bg-[#faad14]/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-[#d48806]">待对账</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
以下划转主站结果未最终确认;若长时间未到账请联系客服(界面文档 §4.10
|
||||
超时说明)。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{logs.pending_reconcile.map((p) => (
|
||||
<div
|
||||
key={p.transfer_no}
|
||||
className="flex flex-wrap items-baseline justify-between gap-1 border-b border-dashed border-border py-2 last:border-0"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{p.type === "transfer_in" ? "转入" : "转出"}{" "}
|
||||
{formatMinorAsCurrency(p.amount, p.currency_code)}
|
||||
</span>
|
||||
<span className="text-xs text-amber-700">处理中</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium">{title}</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WALLET_FLOW_FILTERS.map((f) => (
|
||||
<Button
|
||||
key={f.value || "all"}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filter === f.value ? "default" : "outline"}
|
||||
className="h-8 rounded-full text-xs"
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logsLoading && !logs ? (
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
) : null}
|
||||
|
||||
{logs ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
共 {logs.total} 条记录
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{logs.items.length === 0 ? (
|
||||
<li className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
|
||||
暂无流水
|
||||
</li>
|
||||
) : (
|
||||
logs.items.map((row) => (
|
||||
<LogRow key={row.log_id} item={row} currency={currency} />
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogRow({
|
||||
item,
|
||||
currency,
|
||||
}: {
|
||||
item: WalletLogItem;
|
||||
currency: string;
|
||||
}) {
|
||||
const ccy = item.currency_code || currency;
|
||||
const isIn = item.direction === "in";
|
||||
return (
|
||||
<li className="rounded-xl border bg-card px-3 py-2.5 text-sm shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{logTypeLabel(item.type)}{" "}
|
||||
<span className={isIn ? "text-[#52c41a]" : "text-foreground"}>
|
||||
{isIn ? "+" : "−"}
|
||||
{formatMinorAsCurrency(item.amount_abs, ccy)}
|
||||
</span>
|
||||
</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{item.created_at?.replace("T", " ").slice(0, 19) ?? "—"}{" "}
|
||||
<span className="text-foreground/80">
|
||||
· {txnStatusLabel(item.status)}
|
||||
</span>
|
||||
</p>
|
||||
{item.ref_id ? (
|
||||
<p className="mt-0.5 font-mono text-[11px] text-muted-foreground">
|
||||
{item.ref_id}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
100
src/features/wallet/wallet-logs-screen.tsx
Normal file
100
src/features/wallet/wallet-logs-screen.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getWalletLogs } from "@/api/wallet";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletLogsData } from "@/types/api/wallet-logs";
|
||||
|
||||
/** 独立路由 `/wallet/logs` */
|
||||
export function WalletLogsScreen() {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const currency = useMemo(
|
||||
() => (profile?.default_currency ?? "NPR").toUpperCase(),
|
||||
[profile?.default_currency],
|
||||
);
|
||||
|
||||
const fetchPassRef = useRef(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
if (fetchPassRef.current) {
|
||||
setLoading(true);
|
||||
fetchPassRef.current = false;
|
||||
} else {
|
||||
setLogsLoading(true);
|
||||
}
|
||||
try {
|
||||
const L = await getWalletLogs({
|
||||
page: 1,
|
||||
size: 50,
|
||||
type: filter || undefined,
|
||||
});
|
||||
setLogs(L);
|
||||
} catch (e) {
|
||||
setError(formatWalletClientError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-lg font-semibold tracking-tight">资金流水</h1>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
返回钱包概览
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button type="button" onClick={() => void load()}>
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
title="类型筛选"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
src/features/wallet/wallet-screen.tsx
Normal file
235
src/features/wallet/wallet-screen.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getWalletBalance, getWalletLogs } from "@/api/wallet";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
TransferInDialog,
|
||||
TransferOutDialog,
|
||||
} from "@/features/wallet/wallet-transfer-dialogs";
|
||||
import { WalletLogsBlock } from "@/features/wallet/wallet-logs-block";
|
||||
import { formatMinorAsCurrency } from "@/lib/money";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import type { WalletLogsData } from "@/types/api/wallet-logs";
|
||||
import type { WalletBalanceData } from "@/types/api/wallet-balance";
|
||||
|
||||
export function WalletScreen() {
|
||||
const profile = usePlayerSessionStore((s) => s.profile);
|
||||
|
||||
const [balance, setBalance] = useState<WalletBalanceData | null>(null);
|
||||
const [logs, setLogs] = useState<WalletLogsData | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const currency = useMemo(() => {
|
||||
return (
|
||||
balance?.currency_code ??
|
||||
profile?.default_currency ??
|
||||
"NPR"
|
||||
).toUpperCase();
|
||||
}, [balance?.currency_code, profile?.default_currency]);
|
||||
|
||||
const fetchPassRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
setError(null);
|
||||
if (fetchPassRef.current) {
|
||||
setLoading(true);
|
||||
fetchPassRef.current = false;
|
||||
} else {
|
||||
setLogsLoading(true);
|
||||
}
|
||||
try {
|
||||
const b = await getWalletBalance();
|
||||
if (cancelled) return;
|
||||
setBalance(b);
|
||||
const L = await getWalletLogs({
|
||||
page: 1,
|
||||
size: 50,
|
||||
type: filter || undefined,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setLogs(L);
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(formatWalletClientError(e));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filter]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setError(null);
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const b = await getWalletBalance();
|
||||
setBalance(b);
|
||||
const L = await getWalletLogs({
|
||||
page: 1,
|
||||
size: 50,
|
||||
type: filter || undefined,
|
||||
});
|
||||
setLogs(L);
|
||||
} catch (e) {
|
||||
setError(formatWalletClientError(e));
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">彩票钱包</h1>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/wallet/transfer-in"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "secondary", size: "sm" }),
|
||||
"text-xs",
|
||||
)}
|
||||
>
|
||||
转入页
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet/transfer-out"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "secondary", size: "sm" }),
|
||||
"text-xs",
|
||||
)}
|
||||
>
|
||||
转出页
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet/logs"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "secondary", size: "sm" }),
|
||||
"text-xs",
|
||||
)}
|
||||
>
|
||||
流水页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/hall"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"shrink-0 self-start",
|
||||
)}
|
||||
>
|
||||
返回大厅
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button type="button" onClick={() => void refreshAll()}>
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Wallet className="size-5 opacity-80" aria-hidden />
|
||||
余额
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-12 w-full max-w-xs rounded-lg" />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">彩票钱包余额</p>
|
||||
<p className="font-heading text-2xl font-semibold tabular-nums text-[#52c41a]">
|
||||
{formatMinorAsCurrency(
|
||||
balance?.balance ?? 0,
|
||||
currency,
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
可用{" "}
|
||||
{formatMinorAsCurrency(
|
||||
balance?.available_balance ?? 0,
|
||||
currency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
主站钱包余额{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{balance?.main_balance == null
|
||||
? "—(待接入主站)"
|
||||
: formatMinorAsCurrency(balance.main_balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<TransferInDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
lotteryMinor={Number(balance?.balance ?? 0)}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="wallet"
|
||||
/>
|
||||
<TransferOutDialog
|
||||
idPrefix="wallet-"
|
||||
currency={currency}
|
||||
availableMinor={Number(balance?.available_balance ?? 0)}
|
||||
onSuccess={refreshAll}
|
||||
triggerVariant="wallet"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WalletLogsBlock
|
||||
logs={logs}
|
||||
logsLoading={loading || logsLoading}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
currency={currency}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/features/wallet/wallet-transfer-dialogs.tsx
Normal file
154
src/features/wallet/wallet-transfer-dialogs.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
TransferInPanel,
|
||||
TransferOutPanel,
|
||||
} from "@/features/wallet/wallet-transfer-forms";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BaseProps = {
|
||||
currency: string;
|
||||
onSuccess: () => Promise<void>;
|
||||
/** 避免同页多实例 input id 冲突 */
|
||||
idPrefix?: string;
|
||||
};
|
||||
|
||||
const defaultInTrigger =
|
||||
"!bg-[#52c41a] !text-white shadow-none hover:!bg-[#52c41a]/90";
|
||||
const defaultOutTrigger = "flex-1";
|
||||
|
||||
/** 高保真稿:蓝底白字 */
|
||||
const hallInTrigger =
|
||||
"rounded-lg border-0 !bg-[#1677ff] !text-white shadow-sm hover:!bg-[#1677ff]/90";
|
||||
/** 高保真稿:白底红框红字 */
|
||||
const hallOutTrigger =
|
||||
"rounded-lg !border-2 !border-[#ff4d4f] !bg-white !text-[#ff4d4f] shadow-sm hover:!bg-red-50 dark:!bg-card dark:hover:!bg-red-950/30";
|
||||
|
||||
export function TransferInDialog({
|
||||
currency,
|
||||
lotteryMinor,
|
||||
onSuccess,
|
||||
idPrefix = "",
|
||||
triggerClassName,
|
||||
triggerVariant = "wallet",
|
||||
triggerLabel = "转入",
|
||||
}: BaseProps & {
|
||||
lotteryMinor: number;
|
||||
triggerClassName?: string;
|
||||
triggerVariant?: "wallet" | "hall";
|
||||
triggerLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const triggerCombined = cn(
|
||||
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
|
||||
triggerVariant === "hall" ? hallInTrigger : defaultInTrigger,
|
||||
triggerClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={triggerCombined}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<ArrowDownLeft className="size-4 shrink-0" />
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
<DialogContent showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>转入资金</DialogTitle>
|
||||
<DialogDescription>
|
||||
从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "}
|
||||
{currency})。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TransferInPanel
|
||||
variant="dialog"
|
||||
currency={currency}
|
||||
lotteryMinor={lotteryMinor}
|
||||
idPrefix={idPrefix}
|
||||
onCancel={() => setOpen(false)}
|
||||
onSuccess={async () => {
|
||||
await onSuccess();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferOutDialog({
|
||||
currency,
|
||||
availableMinor,
|
||||
onSuccess,
|
||||
idPrefix = "",
|
||||
triggerClassName,
|
||||
triggerVariant = "wallet",
|
||||
triggerLabel = "转出",
|
||||
}: BaseProps & {
|
||||
availableMinor: number;
|
||||
triggerClassName?: string;
|
||||
triggerVariant?: "wallet" | "hall";
|
||||
triggerLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const triggerCombined = cn(
|
||||
"inline-flex h-10 min-h-10 w-full min-w-0 flex-1 items-center justify-center gap-1.5 px-3 text-sm font-medium",
|
||||
triggerVariant === "hall"
|
||||
? hallOutTrigger
|
||||
: cn(
|
||||
"!border-2 !border-input !bg-secondary !text-secondary-foreground hover:!bg-secondary/80",
|
||||
defaultOutTrigger,
|
||||
),
|
||||
triggerClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={triggerCombined}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<ArrowUpRight className="size-4 shrink-0" />
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
<DialogContent showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>转出资金</DialogTitle>
|
||||
<DialogDescription>
|
||||
划回主站钱包;单笔限额以服务端校验为准。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TransferOutPanel
|
||||
variant="dialog"
|
||||
currency={currency}
|
||||
availableMinor={availableMinor}
|
||||
idPrefix={idPrefix}
|
||||
onCancel={() => setOpen(false)}
|
||||
onSuccess={async () => {
|
||||
await onSuccess();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
414
src/features/wallet/wallet-transfer-forms.tsx
Normal file
414
src/features/wallet/wallet-transfer-forms.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { postWalletTransferIn, postWalletTransferOut } from "@/api/wallet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatMinorAsCurrency, parseDecimalInputToMinor } from "@/lib/money";
|
||||
import { formatWalletClientError } from "@/lib/wallet-api-error";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
/** 处理中 / 待对账:刷新数据后提示用文案即可 */
|
||||
async function handleTransferMaybePending(
|
||||
e: unknown,
|
||||
onRefresh: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (e instanceof LotteryApiBizError && e.code === 1002) {
|
||||
toast.message(e.message || "处理中…");
|
||||
await onRefresh();
|
||||
return true;
|
||||
}
|
||||
if (isAxiosError(e) && e.response?.status === 409) {
|
||||
toast.message("转账处理中,请稍后刷新。");
|
||||
await onRefresh();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type PanelBase = {
|
||||
currency: string;
|
||||
idPrefix?: string;
|
||||
/** 提交成功后刷新余额等 */
|
||||
onSuccess: () => Promise<void>;
|
||||
};
|
||||
|
||||
type PanelVariant = "dialog" | "page";
|
||||
|
||||
/** 弹窗内:取消关闭;独立页:仅展示提交(返回用顶栏或上方链接) */
|
||||
export function TransferInPanel({
|
||||
currency,
|
||||
lotteryMinor,
|
||||
onSuccess,
|
||||
idPrefix = "",
|
||||
onCancel,
|
||||
variant = "dialog",
|
||||
}: PanelBase & {
|
||||
lotteryMinor: number;
|
||||
onCancel: () => void;
|
||||
variant?: PanelVariant;
|
||||
}) {
|
||||
const [amountText, setAmountText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const tid = `${idPrefix}in-amount`;
|
||||
|
||||
const parsedMinor = useMemo(
|
||||
() => parseDecimalInputToMinor(amountText),
|
||||
[amountText],
|
||||
);
|
||||
const previewAfter =
|
||||
parsedMinor != null ? lotteryMinor + parsedMinor : lotteryMinor;
|
||||
|
||||
const submit = async () => {
|
||||
setLocalError(null);
|
||||
if (parsedMinor == null || parsedMinor < 1) {
|
||||
setLocalError("请输入有效金额。");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await postWalletTransferIn({
|
||||
amount: parsedMinor,
|
||||
currency,
|
||||
idempotent_key: crypto.randomUUID(),
|
||||
});
|
||||
toast.success("转入成功,彩票钱包余额已更新。");
|
||||
setAmountText("");
|
||||
await onSuccess();
|
||||
} catch (e) {
|
||||
if (await handleTransferMaybePending(e, onSuccess)) {
|
||||
setLocalError(formatWalletClientError(e));
|
||||
return;
|
||||
}
|
||||
setLocalError(formatWalletClientError(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const footer =
|
||||
variant === "page" ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
处理中…
|
||||
</>
|
||||
) : (
|
||||
"确认转入"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
处理中…
|
||||
</>
|
||||
) : (
|
||||
"确认转入"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 py-1">
|
||||
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
|
||||
<p>
|
||||
主站钱包余额:{" "}
|
||||
<span className="text-muted-foreground">—(待接入主站)</span>
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
彩票钱包余额:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatMinorAsCurrency(lotteryMinor, currency)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={tid}>转入金额</Label>
|
||||
<Input
|
||||
id={tid}
|
||||
inputMode="decimal"
|
||||
placeholder="例如 1000.00"
|
||||
value={amountText}
|
||||
onChange={(ev) => setAmountText(ev.target.value)}
|
||||
disabled={submitting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
转入后彩票余额(预览):{" "}
|
||||
{formatMinorAsCurrency(previewAfter, currency)}
|
||||
</p>
|
||||
</div>
|
||||
{localError ? (
|
||||
<p className="text-sm text-[#ff4d4f]">{localError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferOutPanel({
|
||||
currency,
|
||||
availableMinor,
|
||||
onSuccess,
|
||||
idPrefix = "",
|
||||
onCancel,
|
||||
variant = "dialog",
|
||||
}: PanelBase & {
|
||||
availableMinor: number;
|
||||
onCancel: () => void;
|
||||
variant?: PanelVariant;
|
||||
}) {
|
||||
const [amountText, setAmountText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const tid = `${idPrefix}out-amount`;
|
||||
|
||||
const parsedMinor = useMemo(
|
||||
() => parseDecimalInputToMinor(amountText),
|
||||
[amountText],
|
||||
);
|
||||
const previewAfter =
|
||||
parsedMinor != null
|
||||
? Math.max(0, availableMinor - parsedMinor)
|
||||
: availableMinor;
|
||||
|
||||
const fillAll = () => {
|
||||
const major = availableMinor / 100;
|
||||
setAmountText(major.toFixed(2));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setLocalError(null);
|
||||
if (parsedMinor == null || parsedMinor < 1) {
|
||||
setLocalError("请输入有效金额。");
|
||||
return;
|
||||
}
|
||||
if (parsedMinor > availableMinor) {
|
||||
setLocalError("转出金额不能超过可用余额。");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await postWalletTransferOut({
|
||||
amount: parsedMinor,
|
||||
currency,
|
||||
idempotent_key: crypto.randomUUID(),
|
||||
});
|
||||
toast.success("转出成功,资金将返回主站钱包。");
|
||||
setAmountText("");
|
||||
await onSuccess();
|
||||
} catch (e) {
|
||||
if (await handleTransferMaybePending(e, onSuccess)) {
|
||||
setLocalError(formatWalletClientError(e));
|
||||
return;
|
||||
}
|
||||
setLocalError(formatWalletClientError(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const footer =
|
||||
variant === "page" ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
处理中…
|
||||
</>
|
||||
) : (
|
||||
"确认转出"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
处理中…
|
||||
</>
|
||||
) : (
|
||||
"确认转出"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 py-1">
|
||||
<div className="rounded-lg bg-muted/50 px-3 py-2 text-xs">
|
||||
<p>
|
||||
彩票钱包可用:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatMinorAsCurrency(availableMinor, currency)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<Label htmlFor={tid}>转出金额</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={fillAll}
|
||||
disabled={submitting || availableMinor < 1}
|
||||
>
|
||||
全部转出{" "}
|
||||
{formatMinorAsCurrency(availableMinor, currency)}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
id={tid}
|
||||
inputMode="decimal"
|
||||
placeholder="例如 500.00"
|
||||
value={amountText}
|
||||
onChange={(ev) => setAmountText(ev.target.value)}
|
||||
disabled={submitting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
转出后彩票余额(预览):{" "}
|
||||
{formatMinorAsCurrency(previewAfter, currency)}
|
||||
</p>
|
||||
</div>
|
||||
{localError ? (
|
||||
<p className="text-sm text-[#ff4d4f]">{localError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 独立路由页:转入(仅组合 Card + Panel) */
|
||||
export function TransferInPage({
|
||||
currency,
|
||||
lotteryMinor,
|
||||
onSuccess,
|
||||
}: PanelBase & { lotteryMinor: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
返回钱包
|
||||
</Link>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>转入资金</CardTitle>
|
||||
<CardDescription>
|
||||
从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "}
|
||||
{currency})。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TransferInPanel
|
||||
variant="page"
|
||||
currency={currency}
|
||||
lotteryMinor={lotteryMinor}
|
||||
idPrefix="page-"
|
||||
onSuccess={onSuccess}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 独立路由页:转出 */
|
||||
export function TransferOutPage({
|
||||
currency,
|
||||
availableMinor,
|
||||
onSuccess,
|
||||
}: PanelBase & { availableMinor: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
返回钱包
|
||||
</Link>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>转出资金</CardTitle>
|
||||
<CardDescription>
|
||||
划回主站钱包;单笔限额以服务端校验为准。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TransferOutPanel
|
||||
variant="page"
|
||||
currency={currency}
|
||||
availableMinor={availableMinor}
|
||||
idPrefix="page-"
|
||||
onSuccess={onSuccess}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import axios, {
|
||||
type AxiosResponse,
|
||||
} from "axios";
|
||||
|
||||
import { withPlayerAuthHeader } from "@/lib/lottery-auth";
|
||||
import { setPlayerBearerToken, withPlayerAuthHeader } from "@/lib/lottery-auth";
|
||||
import { clearPersistedPlayerBearerToken } from "@/lib/player-session";
|
||||
import { withLotteryLocaleHeaders } from "@/lib/lottery-locale";
|
||||
import {
|
||||
LotteryApiBizError,
|
||||
@@ -23,6 +24,25 @@ export const lotteryHttp = axios.create({
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
/** 站内接口 401:清本地会话并回入口,与 {@link EntryGate} `session=expired` 衔接 */
|
||||
lotteryHttp.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: unknown) => {
|
||||
if (
|
||||
isAxiosError(error) &&
|
||||
error.response?.status === 401 &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
clearPersistedPlayerBearerToken();
|
||||
setPlayerBearerToken(null);
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.replace("/?session=expired");
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 对 **payload**(通常是 `response.data`)校验信封并成功时返回 `data`;
|
||||
* `code !== 0` 抛 {@link LotteryApiBizError}。
|
||||
|
||||
37
src/lib/money.ts
Normal file
37
src/lib/money.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 与后端约定:金额存最小货币单位(如 NPR 2 位小数 → 分);展示时除以 10^decimals。
|
||||
*/
|
||||
|
||||
const DEFAULT_DECIMAL_PLACES = 2;
|
||||
|
||||
export function formatMinorAsCurrency(
|
||||
minor: number | string,
|
||||
currencyCode: string,
|
||||
decimalPlaces = DEFAULT_DECIMAL_PLACES,
|
||||
): string {
|
||||
const n = typeof minor === "string" ? Number(minor) : minor;
|
||||
if (!Number.isFinite(n)) return `${currencyCode} —`;
|
||||
const divisor = 10 ** decimalPlaces;
|
||||
const major = n / divisor;
|
||||
return `${currencyCode} ${major.toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户输入如 `1000` 或 `1000.5` → 最小货币单位整数。
|
||||
*/
|
||||
export function parseDecimalInputToMinor(
|
||||
raw: string,
|
||||
decimalPlaces = DEFAULT_DECIMAL_PLACES,
|
||||
): number | null {
|
||||
const cleaned = raw.replace(/,/g, "").trim();
|
||||
if (cleaned === "") return null;
|
||||
const n = Number(cleaned);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
const factor = 10 ** decimalPlaces;
|
||||
const minor = Math.round(n * factor);
|
||||
if (!Number.isSafeInteger(minor)) return null;
|
||||
return minor;
|
||||
}
|
||||
43
src/lib/wallet-api-error.ts
Normal file
43
src/lib/wallet-api-error.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
/** 钱包 / 转账 API 对用户展示的中文说明(优先业务码,其次 HTTP) */
|
||||
export function formatWalletClientError(error: unknown): string {
|
||||
if (error instanceof LotteryApiBizError) {
|
||||
const m = WALLET_CODE_MESSAGES[error.code];
|
||||
if (m) return m;
|
||||
if (error.message.trim()) return error.message;
|
||||
}
|
||||
if (isAxiosError(error)) {
|
||||
if (error.response?.status === 401) {
|
||||
return "登录已失效,请返回重新进入。";
|
||||
}
|
||||
if (error.response?.status === 409) {
|
||||
return "转账处理中,请稍后刷新;若长时间未到账请联系客服。";
|
||||
}
|
||||
if (!error.response) {
|
||||
return "网络异常,请检查连接后重试。";
|
||||
}
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return "请求超时,请稍后重试。";
|
||||
}
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
const WALLET_CODE_MESSAGES: Record<number, string> = {
|
||||
1001: "彩票钱包余额不足,请转入或减少转出金额。",
|
||||
1002: "转账处理中,请稍后在本页刷新余额;若长时间未到账请联系客服。",
|
||||
1003: "金额超出单笔或每日限额,请调整金额。",
|
||||
1004: "当前已暂停转入,请稍后再试或联系客服。",
|
||||
1005: "币种无效或未开通。",
|
||||
1006: "当前已暂停转出,请稍后再试或联系客服。",
|
||||
1007: "彩票钱包已冻结,暂无法划转。",
|
||||
1008: "金额格式不正确。",
|
||||
1009: "主站未能完成本次划转,请稍后重试。",
|
||||
1010: "请勿用同一幂等键发起不同金额的转账。",
|
||||
};
|
||||
@@ -9,3 +9,10 @@ export type { HealthData } from "./health";
|
||||
export type { ScopePingData } from "./ping";
|
||||
export type { PlayerMeData } from "./player-me";
|
||||
export type { WalletBalanceData } from "./wallet-balance";
|
||||
export type { WalletTransferBody, WalletTransferResultData } from "./wallet-transfer";
|
||||
export type {
|
||||
GetWalletLogsParams,
|
||||
WalletLogItem,
|
||||
WalletLogsData,
|
||||
WalletPendingTransfer,
|
||||
} from "./wallet-logs";
|
||||
|
||||
@@ -3,8 +3,12 @@ export type PlayerMeData = {
|
||||
id: number;
|
||||
site_code: string;
|
||||
site_player_id: string;
|
||||
username: string;
|
||||
username: string | null;
|
||||
nickname: string | null;
|
||||
default_currency: string;
|
||||
status: number;
|
||||
/** 当次请求解析后的界面语言(zh / en / ne) */
|
||||
locale: string;
|
||||
last_login_at: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
export type WalletBalanceData = {
|
||||
/** 最小货币单位 bigint,序列化可能为 string */
|
||||
balance: string | number;
|
||||
/** 可用余额 = balance - frozen_balance(服务端保证 ≥0) */
|
||||
available_balance: string | number;
|
||||
main_balance: null;
|
||||
currency_code: string;
|
||||
wallet_type: string;
|
||||
|
||||
47
src/types/api/wallet-logs.ts
Normal file
47
src/types/api/wallet-logs.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/** `GET /api/v1/wallet/logs` 单条流水 */
|
||||
export type WalletLogItem = {
|
||||
log_id: string;
|
||||
type: string;
|
||||
biz_type: string;
|
||||
/** 正数为加款,负数为扣款(最小货币单位) */
|
||||
amount: number;
|
||||
amount_abs: number;
|
||||
direction: "in" | "out";
|
||||
currency_code: string;
|
||||
balance_after: number;
|
||||
ref_id: string | null;
|
||||
idempotent_key: string | null;
|
||||
external_ref_no: string | null;
|
||||
status: string;
|
||||
remark: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
/** 待对账划转(主站超时等,PRD §6.2 / §6.7) */
|
||||
export type WalletPendingTransfer = {
|
||||
transfer_no: string;
|
||||
direction: string;
|
||||
type: string;
|
||||
currency_code: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
fail_reason: string | null;
|
||||
idempotent_key: string;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type WalletLogsData = {
|
||||
items: WalletLogItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pending_reconcile: WalletPendingTransfer[];
|
||||
};
|
||||
|
||||
export type GetWalletLogsParams = {
|
||||
page?: number;
|
||||
/** 每页条数(PRD 示例 `size`) */
|
||||
size?: number;
|
||||
/** 逗号分隔:transfer_in,transfer_out,bet,prize,refund */
|
||||
type?: string;
|
||||
};
|
||||
26
src/types/api/wallet-transfer.ts
Normal file
26
src/types/api/wallet-transfer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** `POST /api/v1/wallet/transfer-in` | `transfer-out` 请求体 */
|
||||
export type WalletTransferBody = {
|
||||
/** 最小货币单位正整数 */
|
||||
amount: number;
|
||||
/** 客户端生成的幂等键,重复请求须携带相同键与参数 */
|
||||
idempotent_key: string;
|
||||
/** 不传则使用玩家 `default_currency` / 系统默认 */
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
/** 转入/转出成功时 `data`(含 PRD §10.1.1 示例字段 `balance` / `log_id`) */
|
||||
export type WalletTransferResultData = {
|
||||
transfer_no: string;
|
||||
direction: "in" | "out";
|
||||
currency_code: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
external_ref_no: string | null;
|
||||
/** 成功后彩票钱包余额,与 `lottery_balance_after` 相同 */
|
||||
balance: number;
|
||||
/** 本笔对应主流水号(`wallet_txns.txn_no`) */
|
||||
log_id: string | null;
|
||||
lottery_balance_after: number;
|
||||
lottery_available_after: number;
|
||||
finished_at: string | null;
|
||||
};
|
||||
Reference in New Issue
Block a user