feat: 增强钱包 API 与玩家会话管理
- 新增钱包 API 函数:getWalletLogs(获取钱包日志)、postWalletTransferIn(充值)及 postWalletTransferOut(提现) - 更新钱包相关类型定义,提升类型安全性 - 改进玩家会话管理:若当前无玩家资料,则自动拉取玩家信息 - 增强入口网关对过期会话的错误处理能力 - 更新 UI 组件,以适配新的结构与功能
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user