diff --git a/src/api/index.ts b/src/api/index.ts index 036768f..af9c46c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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"; diff --git a/src/api/wallet.ts b/src/api/wallet.ts index c5cf03a..5c00357 100644 --- a/src/api/wallet.ts +++ b/src/api/wallet.ts @@ -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 { + return lotteryRequest.get( + `${API_V1_PREFIX}/wallet/logs`, + { params }, + ); +} + +/** `POST /api/v1/wallet/transfer-in`(主站 → 彩票) */ +export function postWalletTransferIn( + body: WalletTransferBody, +): Promise { + return lotteryRequest.post( + `${API_V1_PREFIX}/wallet/transfer-in`, + body, + ); +} + +/** `POST /api/v1/wallet/transfer-out`(彩票 → 主站) */ +export function postWalletTransferOut( + body: WalletTransferBody, +): Promise { + return lotteryRequest.post( + `${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"; diff --git a/src/app/(player)/(main)/hall/page.tsx b/src/app/(player)/(main)/hall/page.tsx index fec4ced..adf50fe 100644 --- a/src/app/(player)/(main)/hall/page.tsx +++ b/src/app/(player)/(main)/hall/page.tsx @@ -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 ( - - - 下注大厅 - - 路由已打通;玩法表格、期号与余额将在此页迭代。 - - - - 从入口页授权成功后可刷新本页;鉴权头会从会话存储恢复。 - - - ); +export default function LotteryHallPage() { + return ; } diff --git a/src/app/(player)/(main)/wallet/logs/page.tsx b/src/app/(player)/(main)/wallet/logs/page.tsx new file mode 100644 index 0000000..27c2cc3 --- /dev/null +++ b/src/app/(player)/(main)/wallet/logs/page.tsx @@ -0,0 +1,5 @@ +import { WalletLogsScreen } from "@/features/wallet/wallet-logs-screen"; + +export default function WalletLogsPage() { + return ; +} diff --git a/src/app/(player)/(main)/wallet/page.tsx b/src/app/(player)/(main)/wallet/page.tsx new file mode 100644 index 0000000..3f123ea --- /dev/null +++ b/src/app/(player)/(main)/wallet/page.tsx @@ -0,0 +1,6 @@ +import { WalletScreen } from "@/features/wallet/wallet-screen"; + +/** 界面文档 §4.9 彩票钱包:余额、转入/转出、流水 */ +export default function WalletPage() { + return ; +} diff --git a/src/app/(player)/(main)/wallet/transfer-in/page.tsx b/src/app/(player)/(main)/wallet/transfer-in/page.tsx new file mode 100644 index 0000000..e43be8a --- /dev/null +++ b/src/app/(player)/(main)/wallet/transfer-in/page.tsx @@ -0,0 +1,5 @@ +import { TransferInScreen } from "@/features/wallet/transfer-in-screen"; + +export default function WalletTransferInPage() { + return ; +} diff --git a/src/app/(player)/(main)/wallet/transfer-out/page.tsx b/src/app/(player)/(main)/wallet/transfer-out/page.tsx new file mode 100644 index 0000000..e634d91 --- /dev/null +++ b/src/app/(player)/(main)/wallet/transfer-out/page.tsx @@ -0,0 +1,5 @@ +import { TransferOutScreen } from "@/features/wallet/transfer-out-screen"; + +export default function WalletTransferOutPage() { + return ; +} diff --git a/src/components/layout/player-app-shell.tsx b/src/components/layout/player-app-shell.tsx index 13b724f..7c32bcc 100644 --- a/src/components/layout/player-app-shell.tsx +++ b/src/components/layout/player-app-shell.tsx @@ -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 (
-
- Lottery +
+
+ + Lottery + + +
+
diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx new file mode 100644 index 0000000..e676fa6 --- /dev/null +++ b/src/features/hall/hall-screen.tsx @@ -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 ( +
+ + + + + 下注大厅 + + Issue No.、倒计时、2D/3D/4D 表格与 Submit Bet 将按界面文档 §4.2 接续开发。 + + + + 封盘态、WebSocket 降级轮询等与 PRD §2 一致时再接入。 + + +
+ ); +} diff --git a/src/features/hall/hall-wallet-strip.tsx b/src/features/hall/hall-wallet-strip.tsx new file mode 100644 index 0000000..aeb9199 --- /dev/null +++ b/src/features/hall/hall-wallet-strip.tsx @@ -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(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 ( +
+
+
+
+ +
+
+

+ Wallet Balance +

+ {loading ? ( + + ) : ( +

+ {formatMinorAsCurrency(lotteryMinor, currency)} +

+ )} +
+ + 明细 + +
+
+ +
+ + +
+
+ ); +} diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx index d83d6d9..e40c503 100644 --- a/src/features/player/entry-gate.tsx +++ b/src/features/player/entry-gate.tsx @@ -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; diff --git a/src/features/player/hydrate-player-auth.tsx b/src/features/player/hydrate-player-auth.tsx index c88341d..37babde 100644 --- a/src/features/player/hydrate-player-auth.tsx +++ b/src/features/player/hydrate-player-auth.tsx @@ -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; } diff --git a/src/features/player/player-session-bar.tsx b/src/features/player/player-session-bar.tsx new file mode 100644 index 0000000..14b2e0d --- /dev/null +++ b/src/features/player/player-session-bar.tsx @@ -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 ( +
+ + + +
+

+ {label ?? "…"} +

+ {profile?.default_currency ? ( +

+ {profile.default_currency.toUpperCase()} + {profile.locale ? ( + + {" "} + · {profile.locale} + + ) : null} +

+ ) : null} +
+
+ ); +} diff --git a/src/features/wallet/transfer-in-screen.tsx b/src/features/wallet/transfer-in-screen.tsx new file mode 100644 index 0000000..6cbb1fb --- /dev/null +++ b/src/features/wallet/transfer-in-screen.tsx @@ -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(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 ( +
+ + +
+ ); + } + + return ( + + ); +} diff --git a/src/features/wallet/transfer-out-screen.tsx b/src/features/wallet/transfer-out-screen.tsx new file mode 100644 index 0000000..03b1754 --- /dev/null +++ b/src/features/wallet/transfer-out-screen.tsx @@ -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(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 ( +
+ + +
+ ); + } + + return ( + + ); +} diff --git a/src/features/wallet/wallet-logs-block.tsx b/src/features/wallet/wallet-logs-block.tsx new file mode 100644 index 0000000..f50cbb2 --- /dev/null +++ b/src/features/wallet/wallet-logs-block.tsx @@ -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 = { + 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 ? ( + + + 待对账 + + 以下划转主站结果未最终确认;若长时间未到账请联系客服(界面文档 §4.10 + 超时说明)。 + + + + {logs.pending_reconcile.map((p) => ( +
+ + {p.type === "transfer_in" ? "转入" : "转出"}{" "} + {formatMinorAsCurrency(p.amount, p.currency_code)} + + 处理中 +
+ ))} +
+
+ ) : null} + +
+
+

{title}

+
+ {WALLET_FLOW_FILTERS.map((f) => ( + + ))} +
+
+ + {logsLoading && !logs ? ( + + ) : null} + + {logs ? ( + <> +

+ 共 {logs.total} 条记录 +

+
    + {logs.items.length === 0 ? ( +
  • + 暂无流水 +
  • + ) : ( + logs.items.map((row) => ( + + )) + )} +
+ + ) : null} +
+ + ); +} + +export function LogRow({ + item, + currency, +}: { + item: WalletLogItem; + currency: string; +}) { + const ccy = item.currency_code || currency; + const isIn = item.direction === "in"; + return ( +
  • +
    +
    + + {logTypeLabel(item.type)}{" "} + + {isIn ? "+" : "−"} + {formatMinorAsCurrency(item.amount_abs, ccy)} + + +

    + {item.created_at?.replace("T", " ").slice(0, 19) ?? "—"}{" "} + + · {txnStatusLabel(item.status)} + +

    + {item.ref_id ? ( +

    + {item.ref_id} +

    + ) : null} +
    +
    +
  • + ); +} diff --git a/src/features/wallet/wallet-logs-screen.tsx b/src/features/wallet/wallet-logs-screen.tsx new file mode 100644 index 0000000..187f58a --- /dev/null +++ b/src/features/wallet/wallet-logs-screen.tsx @@ -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(null); + const [filter, setFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [logsLoading, setLogsLoading] = useState(false); + const [error, setError] = useState(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 ( +
    +
    +

    资金流水

    + + 返回钱包概览 + +
    + + {error ? ( + + + 加载失败 + {error} + + + + + + ) : null} + + +
    + ); +} diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx new file mode 100644 index 0000000..2eb872d --- /dev/null +++ b/src/features/wallet/wallet-screen.tsx @@ -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(null); + const [logs, setLogs] = useState(null); + const [filter, setFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [logsLoading, setLogsLoading] = useState(false); + const [error, setError] = useState(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 ( +
    +
    +
    +

    彩票钱包

    +
    + + 转入页 + + + 转出页 + + + 流水页 + +
    +
    + + 返回大厅 + +
    + + {error ? ( + + + 加载失败 + {error} + + + + + + ) : null} + + + + + + 余额 + + + + {loading ? ( + + ) : ( + <> +
    +

    彩票钱包余额

    +

    + {formatMinorAsCurrency( + balance?.balance ?? 0, + currency, + )} +

    +

    + 可用{" "} + {formatMinorAsCurrency( + balance?.available_balance ?? 0, + currency, + )} +

    +
    +
    + 主站钱包余额{" "} + + {balance?.main_balance == null + ? "—(待接入主站)" + : formatMinorAsCurrency(balance.main_balance, currency)} + +
    + + )} + +
    + + +
    +
    +
    + + +
    + ); +} diff --git a/src/features/wallet/wallet-transfer-dialogs.tsx b/src/features/wallet/wallet-transfer-dialogs.tsx new file mode 100644 index 0000000..bbb9634 --- /dev/null +++ b/src/features/wallet/wallet-transfer-dialogs.tsx @@ -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; + /** 避免同页多实例 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 ( + + + + + 转入资金 + + 从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "} + {currency})。 + + + setOpen(false)} + onSuccess={async () => { + await onSuccess(); + setOpen(false); + }} + /> + + + ); +} + +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 ( + + + + + 转出资金 + + 划回主站钱包;单笔限额以服务端校验为准。 + + + setOpen(false)} + onSuccess={async () => { + await onSuccess(); + setOpen(false); + }} + /> + + + ); +} diff --git a/src/features/wallet/wallet-transfer-forms.tsx b/src/features/wallet/wallet-transfer-forms.tsx new file mode 100644 index 0000000..c5c5094 --- /dev/null +++ b/src/features/wallet/wallet-transfer-forms.tsx @@ -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, +): Promise { + 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; +}; + +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(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" ? ( + + ) : ( +
    + + +
    + ); + + return ( + <> +
    +
    +

    + 主站钱包余额:{" "} + —(待接入主站) +

    +

    + 彩票钱包余额:{" "} + + {formatMinorAsCurrency(lotteryMinor, currency)} + +

    +
    +
    + + setAmountText(ev.target.value)} + disabled={submitting} + autoComplete="off" + /> +

    + 转入后彩票余额(预览):{" "} + {formatMinorAsCurrency(previewAfter, currency)} +

    +
    + {localError ? ( +

    {localError}

    + ) : null} +
    + {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(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" ? ( + + ) : ( +
    + + +
    + ); + + return ( + <> +
    +
    +

    + 彩票钱包可用:{" "} + + {formatMinorAsCurrency(availableMinor, currency)} + +

    +
    +
    +
    + + +
    + setAmountText(ev.target.value)} + disabled={submitting} + autoComplete="off" + /> +

    + 转出后彩票余额(预览):{" "} + {formatMinorAsCurrency(previewAfter, currency)} +

    +
    + {localError ? ( +

    {localError}

    + ) : null} +
    + {footer} + + ); +} + +/** 独立路由页:转入(仅组合 Card + Panel) */ +export function TransferInPage({ + currency, + lotteryMinor, + onSuccess, +}: PanelBase & { lotteryMinor: number }) { + return ( +
    + + + 返回钱包 + + + + 转入资金 + + 从主站钱包划入彩票钱包(最小单笔以服务端校验为准,默认约 1.00{" "} + {currency})。 + + + + {}} + /> + + +
    + ); +} + +/** 独立路由页:转出 */ +export function TransferOutPage({ + currency, + availableMinor, + onSuccess, +}: PanelBase & { availableMinor: number }) { + return ( +
    + + + 返回钱包 + + + + 转出资金 + + 划回主站钱包;单笔限额以服务端校验为准。 + + + + {}} + /> + + +
    + ); +} diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index d3ca12b..d86dfbb 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -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}。 diff --git a/src/lib/money.ts b/src/lib/money.ts new file mode 100644 index 0000000..7e011aa --- /dev/null +++ b/src/lib/money.ts @@ -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; +} diff --git a/src/lib/wallet-api-error.ts b/src/lib/wallet-api-error.ts new file mode 100644 index 0000000..0b715e3 --- /dev/null +++ b/src/lib/wallet-api-error.ts @@ -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 = { + 1001: "彩票钱包余额不足,请转入或减少转出金额。", + 1002: "转账处理中,请稍后在本页刷新余额;若长时间未到账请联系客服。", + 1003: "金额超出单笔或每日限额,请调整金额。", + 1004: "当前已暂停转入,请稍后再试或联系客服。", + 1005: "币种无效或未开通。", + 1006: "当前已暂停转出,请稍后再试或联系客服。", + 1007: "彩票钱包已冻结,暂无法划转。", + 1008: "金额格式不正确。", + 1009: "主站未能完成本次划转,请稍后重试。", + 1010: "请勿用同一幂等键发起不同金额的转账。", +}; diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 3acdcb9..4d6c721 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -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"; diff --git a/src/types/api/player-me.ts b/src/types/api/player-me.ts index 3ffff16..7397842 100644 --- a/src/types/api/player-me.ts +++ b/src/types/api/player-me.ts @@ -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; }; diff --git a/src/types/api/wallet-balance.ts b/src/types/api/wallet-balance.ts index 5ca8003..c376343 100644 --- a/src/types/api/wallet-balance.ts +++ b/src/types/api/wallet-balance.ts @@ -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; diff --git a/src/types/api/wallet-logs.ts b/src/types/api/wallet-logs.ts new file mode 100644 index 0000000..fda507f --- /dev/null +++ b/src/types/api/wallet-logs.ts @@ -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; +}; diff --git a/src/types/api/wallet-transfer.ts b/src/types/api/wallet-transfer.ts new file mode 100644 index 0000000..1feb108 --- /dev/null +++ b/src/types/api/wallet-transfer.ts @@ -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; +};