Checkpoint: v11: Bridge页面增强 - Gas费估算显示(含原生代币说明)、交易历史复制哈希+区块浏览器快捷按钮、交易成功弹窗(含到账时间、复制哈希、查看详情)

This commit is contained in:
Manus 2026-03-10 04:53:41 -04:00
parent 2eff084785
commit 4bdb118cb2
2 changed files with 296 additions and 36 deletions

View File

@ -5,7 +5,7 @@
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { ArrowDown, ArrowLeft, ExternalLink, Loader2, RefreshCw, Zap, History, ChevronDown, ChevronUp } from "lucide-react"; import { ArrowDown, ArrowLeft, ExternalLink, Loader2, RefreshCw, Zap, History, ChevronDown, ChevronUp, Copy, CheckCheck, X } from "lucide-react";
import { Link } from "wouter"; import { Link } from "wouter";
import { WalletSelector } from "@/components/WalletSelector"; import { WalletSelector } from "@/components/WalletSelector";
import { useWallet } from "@/hooks/useWallet"; import { useWallet } from "@/hooks/useWallet";
@ -68,6 +68,18 @@ const T = {
approve: "授权", approve: "授权",
executeSwap: "执行跨链交换", executeSwap: "执行跨链交换",
lang: "EN", lang: "EN",
estGasNative: "Gas费用{symbol}支付)",
estTime: "预计到账时间",
gasNote: "Gas 费用{symbol}{chain}原生代币)支付,请确保钱包中有足够的{symbol}",
copyHash: "复制哈希",
copied: "已复制!",
viewExplorer: "在区块浏览器中查看",
txSuccessModal: "交易成功",
txSuccessDesc: "您的跨链交易已提交XIC 代币将在以下时间内到账:",
viewDetails: "查看交易详情",
close: "关闭",
estimatedArrival: "预计到账",
gasPayWith: "Gas 支付方式",
}, },
en: { en: {
title: "Buy XIC from Any Chain", title: "Buy XIC from Any Chain",
@ -121,6 +133,18 @@ const T = {
approve: "Approve", approve: "Approve",
executeSwap: "Execute Cross-Chain Swap", executeSwap: "Execute Cross-Chain Swap",
lang: "中文", lang: "中文",
estGasNative: "Gas Fee (paid in {symbol})",
estTime: "Estimated Arrival",
gasNote: "Gas fee is paid in {symbol} ({chain} native token). Ensure you have enough {symbol} in your wallet.",
copyHash: "Copy Hash",
copied: "Copied!",
viewExplorer: "View in Explorer",
txSuccessModal: "Transaction Submitted!",
txSuccessDesc: "Your cross-chain transaction has been submitted. XIC tokens will arrive within:",
viewDetails: "View Transaction Details",
close: "Close",
estimatedArrival: "Est. Arrival",
gasPayWith: "Gas Payment",
}, },
}; };
@ -173,8 +197,16 @@ interface RouteQuote {
type: string; type: string;
tool: string; tool: string;
toolDetails?: { name: string; logoURI?: string }; toolDetails?: { name: string; logoURI?: string };
estimate?: {
executionDuration?: number; // seconds
gasCosts?: Array<{ amountUSD?: string; amount?: string; token?: { symbol: string; decimals: number } }>;
};
}>; }>;
gasCostUSD?: string; gasCostUSD?: string;
estimate?: {
executionDuration?: number; // total seconds
gasCosts?: Array<{ amountUSD?: string; amount?: string; token?: { symbol: string; decimals: number } }>;
};
} }
// ─── Li.Fi API helpers ──────────────────────────────────────────────────────── // ─── Li.Fi API helpers ────────────────────────────────────────────────────────
@ -221,6 +253,38 @@ function getTxUrl(chainId: number, txHash: string): string {
return `${chain?.explorerUrl ?? "https://bscscan.com"}/tx/${txHash}`; return `${chain?.explorerUrl ?? "https://bscscan.com"}/tx/${txHash}`;
} }
// ─── Format duration (seconds) to human-readable ─────────────────────────────
function formatDuration(seconds: number): string {
if (seconds < 60) return `~${seconds}s`;
const mins = Math.ceil(seconds / 60);
if (mins < 60) return `~${mins} min`;
const hrs = Math.ceil(mins / 60);
return `~${hrs} hr`;
}
// ─── Get total estimated duration from quote ─────────────────────────────────
function getEstimatedDuration(quote: RouteQuote): number {
if (quote.estimate?.executionDuration) return quote.estimate.executionDuration;
// Sum up step durations
return quote.steps.reduce((acc, step) => acc + (step.estimate?.executionDuration ?? 0), 0);
}
// ─── Get gas cost info from quote ────────────────────────────────────────────
function getGasCostInfo(quote: RouteQuote): { usd: string; nativeSymbol: string; nativeAmount: string } | null {
// Try top-level gasCostUSD first
const usd = quote.gasCostUSD;
// Try to find native token info from steps
const firstGasCost = quote.estimate?.gasCosts?.[0] ?? quote.steps[0]?.estimate?.gasCosts?.[0];
if (!usd && !firstGasCost) return null;
return {
usd: usd ? `$${parseFloat(usd).toFixed(3)}` : "N/A",
nativeSymbol: firstGasCost?.token?.symbol ?? "",
nativeAmount: firstGasCost?.amount && firstGasCost?.token
? (parseFloat(firstGasCost.amount) / Math.pow(10, firstGasCost.token.decimals)).toFixed(6)
: "",
};
}
// ─── Main Bridge Page ───────────────────────────────────────────────────────── // ─── Main Bridge Page ─────────────────────────────────────────────────────────
export default function Bridge() { export default function Bridge() {
// ── Language ── // ── Language ──
@ -244,6 +308,8 @@ export default function Bridge() {
const [txHash, setTxHash] = useState<string | null>(null); const [txHash, setTxHash] = useState<string | null>(null);
const [completedTxs, setCompletedTxs] = useState(0); const [completedTxs, setCompletedTxs] = useState(0);
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [copiedHash, setCopiedHash] = useState<string | null>(null);
// ── tRPC mutations/queries ── // ── tRPC mutations/queries ──
const recordOrder = trpc.bridge.recordOrder.useMutation(); const recordOrder = trpc.bridge.recordOrder.useMutation();
@ -374,7 +440,7 @@ export default function Bridge() {
setTxHash(hash); setTxHash(hash);
setExecStep("done"); setExecStep("done");
setCompletedTxs(c => c + 1); setCompletedTxs(c => c + 1);
toast.success(t.txSuccess); setShowSuccessModal(true);
// Record in DB // Record in DB
const xicReceived = (parseFloat(quote.toAmount) / 1e18).toFixed(6); const xicReceived = (parseFloat(quote.toAmount) / 1e18).toFixed(6);
@ -401,7 +467,7 @@ export default function Bridge() {
setTxHash(hash); setTxHash(hash);
setExecStep("done"); setExecStep("done");
setCompletedTxs(c => c + 1); setCompletedTxs(c => c + 1);
toast.success(t.txSuccess); setShowSuccessModal(true);
// Record in DB // Record in DB
const xicReceived = (parseFloat(quote.toAmount) / 1e18).toFixed(6); const xicReceived = (parseFloat(quote.toAmount) / 1e18).toFixed(6);
@ -449,6 +515,25 @@ export default function Bridge() {
return `${t.buyXIC} ${xicAmount} XIC`; return `${t.buyXIC} ${xicAmount} XIC`;
}, [execStep, t, xicAmount]); }, [execStep, t, xicAmount]);
// ── Copy hash to clipboard ──
const copyHashToClipboard = useCallback(async (hash: string) => {
try {
await navigator.clipboard.writeText(hash);
setCopiedHash(hash);
setTimeout(() => setCopiedHash(null), 2000);
} catch {
// fallback
const el = document.createElement("textarea");
el.value = hash;
document.body.appendChild(el);
el.select();
document.execCommand("copy");
document.body.removeChild(el);
setCopiedHash(hash);
setTimeout(() => setCopiedHash(null), 2000);
}
}, []);
return ( return (
<div <div
className="min-h-screen" className="min-h-screen"
@ -651,34 +736,72 @@ export default function Bridge() {
</div> </div>
</div> </div>
{/* Route info */} {/* Route info + Gas fee + Estimated time */}
{quote && !quoting && ( {quote && !quoting && (() => {
<div const gasCostInfo = getGasCostInfo(quote);
className="rounded-xl p-3 space-y-1.5 text-xs" const duration = getEstimatedDuration(quote);
style={{ background: "rgba(0,212,255,0.04)", border: "1px solid rgba(0,212,255,0.1)" }} const nativeSymbol = fromChain.symbol; // BNB/ETH/MATIC/AVAX
> return (
<div className="flex justify-between"> <div
<span className="text-white/40">{t.route}</span> className="rounded-xl p-3 space-y-1.5 text-xs"
<span className="text-white/70">{fromChain.name} USDT BSC XIC</span> style={{ background: "rgba(0,212,255,0.04)", border: "1px solid rgba(0,212,255,0.1)" }}
</div> >
<div className="flex justify-between">
<span className="text-white/40">{t.protocol}</span>
<span className="text-white/70">
{quote.steps.map((s) => s.toolDetails?.name ?? s.tool).join(" → ")}
</span>
</div>
{quote.gasCostUSD && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-white/40">{t.estGas}</span> <span className="text-white/40">{t.route}</span>
<span className="text-white/70">${quote.gasCostUSD}</span> <span className="text-white/70">{fromChain.name} USDT BSC XIC</span>
</div>
<div className="flex justify-between">
<span className="text-white/40">{t.protocol}</span>
<span className="text-white/70">
{quote.steps.map((s) => s.toolDetails?.name ?? s.tool).join(" → ")}
</span>
</div>
{/* Gas fee row with native token info */}
<div className="flex justify-between items-start">
<span className="text-white/40">{t.gasPayWith}</span>
<div className="text-right">
<span className="text-amber-400/80 font-semibold">{nativeSymbol}</span>
{gasCostInfo && (
<span className="text-white/50 ml-1">
({gasCostInfo.usd}
{gasCostInfo.nativeAmount && gasCostInfo.nativeSymbol
? `${gasCostInfo.nativeAmount} ${gasCostInfo.nativeSymbol}`
: ""}
)
</span>
)}
</div>
</div>
{/* Gas note: explain which token is used */}
<div
className="rounded-lg px-2 py-1.5 text-xs"
style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.15)" }}
>
<span className="text-amber-400/60"> </span>
<span className="text-white/40">
{t.gasNote
.replace(/\{symbol\}/g, nativeSymbol)
.replace(/\{chain\}/g, fromChain.name)}
</span>
</div>
{/* Estimated arrival time */}
{duration > 0 && (
<div className="flex justify-between">
<span className="text-white/40">{t.estimatedArrival}</span>
<span className="text-green-400/80 font-semibold">{formatDuration(duration)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-white/40">{t.slippage}</span>
<span className="text-white/70">3%</span>
</div> </div>
)}
<div className="flex justify-between">
<span className="text-white/40">{t.slippage}</span>
<span className="text-white/70">3%</span>
</div> </div>
</div> );
)} })()}
{/* ── Wallet Selector (inline, not a popup) ── */} {/* ── Wallet Selector (inline, not a popup) ── */}
{!wallet.address && ( {!wallet.address && (
@ -901,14 +1024,26 @@ export default function Bridge() {
<span className="text-sm font-semibold text-white"> <span className="text-sm font-semibold text-white">
{order.fromAmount} USDT {Number(order.toAmount).toLocaleString(undefined, { maximumFractionDigits: 2 })} XIC {order.fromAmount} USDT {Number(order.toAmount).toLocaleString(undefined, { maximumFractionDigits: 2 })} XIC
</span> </span>
<a <div className="flex items-center gap-1">
href={getTxUrl(order.fromChainId, order.txHash)} <button
target="_blank" onClick={() => copyHashToClipboard(order.txHash)}
rel="noopener noreferrer" className="p-1 rounded transition-colors hover:bg-white/10"
className="text-blue-400/60 hover:text-blue-400 transition-colors" title={t.copyHash}
> >
<ExternalLink size={12} /> {copiedHash === order.txHash
</a> ? <CheckCheck size={12} className="text-green-400" />
: <Copy size={12} className="text-white/30 hover:text-white/60" />}
</button>
<a
href={getTxUrl(order.fromChainId, order.txHash)}
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded transition-colors hover:bg-white/10"
title={t.viewExplorer}
>
<ExternalLink size={12} className="text-blue-400/60 hover:text-blue-400" />
</a>
</div>
</div> </div>
<div className="text-xs text-white/20"> <div className="text-xs text-white/20">
{new Date(order.createdAt).toLocaleString()} {new Date(order.createdAt).toLocaleString()}
@ -950,6 +1085,113 @@ export default function Bridge() {
{t.disclaimer} {t.disclaimer}
</div> </div>
</main> </main>
{/* ── Transaction Success Modal ── */}
{showSuccessModal && txHash && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.8)", backdropFilter: "blur(8px)" }}
onClick={(e) => { if (e.target === e.currentTarget) setShowSuccessModal(false); }}
>
<div
className="w-full max-w-sm rounded-2xl p-6 space-y-4"
style={{
background: "linear-gradient(135deg, #0d1117 0%, #0a0a0f 100%)",
border: "1px solid rgba(0,230,118,0.3)",
boxShadow: "0 0 60px rgba(0,230,118,0.15)",
}}
>
{/* Close button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ background: "rgba(0,230,118,0.15)", border: "1px solid rgba(0,230,118,0.3)" }}
>
<CheckCheck size={16} className="text-green-400" />
</div>
<span className="font-bold text-white" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
{t.txSuccessModal}
</span>
</div>
<button
onClick={() => setShowSuccessModal(false)}
className="text-white/30 hover:text-white/60 transition-colors"
>
<X size={18} />
</button>
</div>
{/* Description */}
<p className="text-sm text-white/50">{t.txSuccessDesc}</p>
{/* Estimated arrival */}
{quote && getEstimatedDuration(quote) > 0 && (
<div
className="rounded-xl p-3 flex items-center justify-between"
style={{ background: "rgba(0,230,118,0.06)", border: "1px solid rgba(0,230,118,0.15)" }}
>
<span className="text-xs text-white/40">{t.estimatedArrival}</span>
<span className="text-green-400 font-bold text-sm">{formatDuration(getEstimatedDuration(quote))}</span>
</div>
)}
{/* TX Hash */}
<div
className="rounded-xl p-3 space-y-2"
style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.08)" }}
>
<div className="text-xs text-white/30">{t.txHash}</div>
<div className="flex items-center gap-2">
<span className="flex-1 text-xs font-mono text-white/60 truncate">
{txHash}
</span>
</div>
{/* Action buttons */}
<div className="flex gap-2">
<button
onClick={() => copyHashToClipboard(txHash)}
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold transition-all"
style={{
background: copiedHash === txHash ? "rgba(0,230,118,0.12)" : "rgba(255,255,255,0.06)",
border: copiedHash === txHash ? "1px solid rgba(0,230,118,0.3)" : "1px solid rgba(255,255,255,0.1)",
color: copiedHash === txHash ? "#00e676" : "rgba(255,255,255,0.5)",
}}
>
{copiedHash === txHash ? <CheckCheck size={12} /> : <Copy size={12} />}
{copiedHash === txHash ? t.copied : t.copyHash}
</button>
<a
href={getTxUrl(fromChain.id, txHash)}
target="_blank"
rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold transition-all"
style={{
background: "rgba(0,212,255,0.08)",
border: "1px solid rgba(0,212,255,0.2)",
color: "#00d4ff",
}}
>
<ExternalLink size={12} />
{t.viewDetails}
</a>
</div>
</div>
{/* Close */}
<button
onClick={() => setShowSuccessModal(false)}
className="w-full py-3 rounded-xl text-sm font-semibold transition-all"
style={{
background: "linear-gradient(135deg, #f0b429, #e09820)",
color: "#0a0a0f",
}}
>
{t.close}
</button>
</div>
</div>
)}
</div> </div>
); );
} }

18
todo.md
View File

@ -123,3 +123,21 @@
- [ ] 错误提示"Wallet connection cancelled"改为中英文双语 - [ ] 错误提示"Wallet connection cancelled"改为中英文双语
- [ ] Bridge 页面添加中英文语言切换支持(与主页同步) - [ ] Bridge 页面添加中英文语言切换支持(与主页同步)
- [ ] 信息卡片"5岁以上"应为"5条以上"(支持链数量) - [ ] 信息卡片"5岁以上"应为"5条以上"(支持链数量)
## v11 Bridge增强功能
- [ ] Gas费估算显示在"YOU RECEIVE"区域下方显示预估Gas费源链原生代币和预计到账时间
- [ ] Gas费说明文案说明Gas用源链原生代币支付BSC用BNBETH用ETHPolygon用MATIC等
- [ ] 交易历史"复制交易哈希"快捷按钮
- [ ] 交易历史"在区块浏览器中查看"快捷按钮
- [ ] 交易成功弹窗提示(附查看交易详情链接)
- [ ] 浏览器全流程测试
- [ ] 构建并部署到AI服务器
- [ ] 记录部署日志
## v11 钱包连接卡死修复(来自用户反馈)
- [ ] 修复WalletSelector连接卡死连接超时30s自动重置状态
- [ ] 修复用户取消钱包弹窗后状态不重置error code 4001/4100处理
- [ ] 修复连接成功后回调不触发accounts事件监听改为直接返回值处理
- [ ] 确保每次点击钱包按钮都能重新触发钱包弹窗