Checkpoint: v14: Bridge页面功能完善 - 1) 修复Connect Wallet按钮:使用createPortal将WalletSelector渲染到document.body,z-index:9999,支持点击遮罩关闭;2) 管理员后台新增Bridge Orders标签页(Bridge Intents表格+Bridge Orders表格,状态过滤器,手动标记分发/确认功能);3) 后端新增admin.listBridgeOrders和admin.updateBridgeOrder路由

This commit is contained in:
Manus 2026-03-10 06:34:14 -04:00
parent 0659cd71cb
commit 84dd7d288f
4 changed files with 382 additions and 14 deletions

View File

@ -428,7 +428,11 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "confirmed" | "distributed" | "failed">("all");
const [markingId, setMarkingId] = useState<number | null>(null);
const [distributeTxInput, setDistributeTxInput] = useState<Record<number, string>>({});
const [activeTab, setActiveTab] = useState<"purchases" | "intents" | "settings">("purchases");
const [activeTab, setActiveTab] = useState<"purchases" | "intents" | "bridge" | "settings">("purchases");
const [bridgePage, setBridgePage] = useState(1);
const [bridgeStatusFilter, setBridgeStatusFilter] = useState<"all" | "pending" | "confirmed" | "distributed" | "failed">("all");
const [bridgeDistribTxInput, setBridgeDistribTxInput] = useState<Record<number, string>>({});
const [updatingBridgeId, setUpdatingBridgeId] = useState<number | null>(null);
const { data: statsData, refetch: refetchStats } = trpc.admin.stats.useQuery({ token });
const { data: intentsData, isLoading: intentsLoading } = trpc.admin.listIntents.useQuery({ token, showAll: false });
@ -439,6 +443,30 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
status: statusFilter,
});
const { data: bridgeData, refetch: refetchBridge, isLoading: bridgeLoading } = trpc.admin.listBridgeOrders.useQuery({
token,
page: bridgePage,
limit: 20,
status: bridgeStatusFilter,
});
const updateBridgeOrderMutation = trpc.admin.updateBridgeOrder.useMutation({
onSuccess: () => {
refetchBridge();
setUpdatingBridgeId(null);
},
});
const handleUpdateBridgeOrder = (id: number, status: "pending" | "confirmed" | "distributed" | "failed") => {
setUpdatingBridgeId(id);
updateBridgeOrderMutation.mutate({
token,
orderId: id,
status,
distributeTxHash: bridgeDistribTxInput[id] || undefined,
});
};
const markDistributedMutation = trpc.admin.markDistributed.useMutation({
onSuccess: () => {
refetchPurchases();
@ -597,6 +625,22 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
</span>
)}
</button>
<button
onClick={() => setActiveTab("bridge")}
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
style={{
background: activeTab === "bridge" ? "rgba(138,71,229,0.15)" : "rgba(255,255,255,0.04)",
border: activeTab === "bridge" ? "1px solid rgba(138,71,229,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: activeTab === "bridge" ? "#a855f7" : "rgba(255,255,255,0.5)",
}}
>
Bridge Orders
{bridgeData && bridgeData.total > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs" style={{ background: "rgba(138,71,229,0.2)", color: "#a855f7" }}>
{bridgeData.total}
</span>
)}
</button>
<button
onClick={() => setActiveTab("settings")}
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
@ -833,6 +877,197 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
</div>
)}
{/* ── Bridge Orders Panel ── */}
{activeTab === "bridge" && (
<div className="space-y-4">
{/* Bridge Intents */}
<div className="rounded-2xl overflow-hidden" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(138,71,229,0.2)" }}>
<div className="px-5 py-4" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
<h3 className="font-semibold text-white/80" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
Bridge Intents
<span className="text-white/40 text-sm ml-2">(users who registered intent but transfer not yet detected)</span>
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{["ID", "Chain", "Sender", "XIC Receive Addr", "Expected USDT", "Matched", "Time"].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/30">{h}</th>
))}
</tr>
</thead>
<tbody>
{bridgeData?.intents && bridgeData.intents.length > 0 ? bridgeData.intents.map((intent, i) => {
const chainNames: Record<number, string> = { 56: "BSC", 1: "ETH", 137: "POLY", 42161: "ARB", 43114: "AVAX" };
return (
<tr key={intent.id} style={{ borderBottom: i < bridgeData.intents.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none" }}>
<td className="px-4 py-3 text-white/50 text-xs">{intent.id}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full" style={{ background: "rgba(138,71,229,0.15)", color: "#a855f7" }}>
{chainNames[intent.fromChainId] ?? `Chain ${intent.fromChainId}`}
</span>
</td>
<td className="px-4 py-3">{formatAddress(intent.senderAddress ?? null)}</td>
<td className="px-4 py-3">{formatAddress(intent.xicReceiveAddress)}</td>
<td className="px-4 py-3 text-white/80">
{intent.expectedUsdt ? `$${intent.expectedUsdt.toLocaleString()}` : <span className="text-white/30"></span>}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${intent.matched ? "text-green-400" : "text-yellow-400"}`}
style={{ background: intent.matched ? "rgba(0,230,118,0.1)" : "rgba(240,180,41,0.1)" }}>
{intent.matched ? "Matched" : "Pending"}
</span>
</td>
<td className="px-4 py-3 text-white/30 text-xs">{formatDate(intent.createdAt)}</td>
</tr>
);
}) : (
<tr><td colSpan={7} className="px-4 py-8 text-center text-white/30 text-sm">No bridge intents yet</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Bridge Orders */}
<div className="rounded-2xl overflow-hidden" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)" }}>
<div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
<h3 className="font-semibold text-white/80" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
Bridge Orders
<span className="text-white/40 text-sm ml-2">(confirmed USDT deposits awaiting XIC distribution)</span>
</h3>
{/* Status Filter */}
<div className="flex gap-2">
{(["all", "pending", "confirmed", "distributed", "failed"] as const).map(s => (
<button
key={s}
onClick={() => { setBridgeStatusFilter(s); setBridgePage(1); }}
className="px-2 py-1 rounded-lg text-xs font-medium transition-all"
style={{
background: bridgeStatusFilter === s ? "rgba(138,71,229,0.2)" : "rgba(255,255,255,0.04)",
border: bridgeStatusFilter === s ? "1px solid rgba(138,71,229,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: bridgeStatusFilter === s ? "#a855f7" : "rgba(255,255,255,0.4)",
}}
>
{s}
</button>
))}
</div>
</div>
{bridgeLoading ? (
<div className="flex justify-center py-12"><span className="text-white/30">Loading...</span></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{["ID", "TX Hash", "Chain", "Sender", "USDT", "XIC", "XIC Receive Addr", "Status", "Distribute TX", "Action"].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/30">{h}</th>
))}
</tr>
</thead>
<tbody>
{bridgeData?.orders && bridgeData.orders.length > 0 ? bridgeData.orders.map((order, i) => {
const chainNames: Record<number, string> = { 56: "BSC", 1: "ETH", 137: "POLY", 42161: "ARB", 43114: "AVAX" };
const isUpdating = updatingBridgeId === order.id;
return (
<tr key={order.id} style={{ borderBottom: i < bridgeData.orders.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none" }}>
<td className="px-4 py-3 text-white/50 text-xs">{order.id}</td>
<td className="px-4 py-3">{formatAddress(order.txHash)}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full" style={{ background: "rgba(138,71,229,0.15)", color: "#a855f7" }}>
{chainNames[order.fromChainId] ?? `Chain ${order.fromChainId}`}
</span>
</td>
<td className="px-4 py-3">{formatAddress(order.walletAddress)}</td>
<td className="px-4 py-3 text-amber-400 font-semibold">${order.fromAmount.toLocaleString()}</td>
<td className="px-4 py-3 text-cyan-400 font-semibold">{order.toAmount.toLocaleString(undefined, { maximumFractionDigits: 0 })} XIC</td>
<td className="px-4 py-3">{formatAddress(order.xicReceiveAddress ?? null)}</td>
<td className="px-4 py-3"><StatusBadge status={order.status as any} /></td>
<td className="px-4 py-3">
{order.status !== "distributed" ? (
<input
type="text"
value={bridgeDistribTxInput[order.id] || ""}
onChange={e => setBridgeDistribTxInput(prev => ({ ...prev, [order.id]: e.target.value }))}
placeholder="0x... BSC TX hash"
className="w-32 rounded px-2 py-1 text-xs text-white outline-none"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.12)", fontFamily: "'JetBrains Mono', monospace" }}
/>
) : (
formatAddress(order.distributeTxHash ?? null)
)}
</td>
<td className="px-4 py-3">
{order.status === "confirmed" && (
<button
onClick={() => handleUpdateBridgeOrder(order.id, "distributed")}
disabled={isUpdating}
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-all"
style={{
background: isUpdating ? "rgba(0,230,118,0.1)" : "rgba(0,230,118,0.2)",
border: "1px solid rgba(0,230,118,0.4)",
color: "#00e676",
cursor: isUpdating ? "not-allowed" : "pointer",
}}
>
{isUpdating ? "..." : "Mark Distributed"}
</button>
)}
{order.status === "pending" && (
<button
onClick={() => handleUpdateBridgeOrder(order.id, "confirmed")}
disabled={isUpdating}
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-all"
style={{
background: "rgba(0,212,255,0.15)",
border: "1px solid rgba(0,212,255,0.3)",
color: "#00d4ff",
}}
>
{isUpdating ? "..." : "Confirm"}
</button>
)}
</td>
</tr>
);
}) : (
<tr><td colSpan={10} className="px-4 py-8 text-center text-white/30 text-sm">No bridge orders yet</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{bridgeData && bridgeData.total > 20 && (
<div className="flex items-center justify-between px-5 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
<span className="text-xs text-white/30">{bridgeData.total} total orders</span>
<div className="flex gap-2">
<button
onClick={() => setBridgePage(p => Math.max(1, p - 1))}
disabled={bridgePage === 1}
className="px-3 py-1.5 rounded-lg text-xs transition-all"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: bridgePage === 1 ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.6)" }}
>
Prev
</button>
<span className="px-3 py-1.5 text-xs text-white/40">Page {bridgePage} / {Math.ceil(bridgeData.total / 20)}</span>
<button
onClick={() => setBridgePage(p => p + 1)}
disabled={bridgePage >= Math.ceil(bridgeData.total / 20)}
className="px-3 py-1.5 rounded-lg text-xs transition-all"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: bridgePage >= Math.ceil(bridgeData.total / 20) ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.6)" }}
>
Next
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* ── Site Settings Panel ── */}
{activeTab === "settings" && (
<SettingsPanel token={token} />

View File

@ -4,6 +4,7 @@
// Supports: BSC, ETH, Polygon, Arbitrum, Avalanche
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import {
@ -753,18 +754,48 @@ export default function Bridge() {
{/* ─── Disclaimer ─────────────────────────────────────────────────── */}
<p className="text-xs text-white/25 text-center leading-relaxed">{t.disclaimer}</p>
</div>
{/* ─── Wallet Selector Modal ───────────────────────────────────────────── */}
{showWalletSelector && (
<WalletSelector
lang={lang}
onAddressDetected={(address: string) => {
setXicReceiveAddress(address);
setShowWalletSelector(false);
}}
connectedAddress={wallet.address || undefined}
/>
)}
</div>
{/* ─── Wallet Selector Modal (Portal) ───────────────────────────────── */}
{showWalletSelector && createPortal(
<div
className="fixed inset-0 z-[9999] flex items-end sm:items-center justify-center sm:p-4"
style={{ background: "rgba(0,0,0,0.85)", backdropFilter: "blur(8px)" }}
onClick={(e) => { if (e.target === e.currentTarget) setShowWalletSelector(false); }}
>
<div
className="w-full sm:max-w-md rounded-t-2xl sm:rounded-2xl p-5 relative overflow-y-auto"
style={{ background: "rgba(10,10,20,0.98)", border: "1px solid rgba(240,180,41,0.3)", boxShadow: "0 0 40px rgba(240,180,41,0.15)", maxHeight: "85vh" }}
>
<button
onClick={() => setShowWalletSelector(false)}
className="absolute top-4 right-4 text-white/40 hover:text-white/80 transition-colors"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<h3 className="text-lg font-bold text-white mb-1" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
{lang === "zh" ? "连接钱包" : "Connect Wallet"}
</h3>
<p className="text-xs text-white/40 mb-4">
{lang === "zh" ? "连接钱包后自动填入XIC接收地址" : "Your wallet address will be auto-filled as XIC receive address"}
</p>
<WalletSelector
lang={lang}
compact={false}
showTron={false}
connectedAddress={wallet.address || undefined}
onAddressDetected={async (address: string, _network, rawProvider) => {
if (rawProvider && wallet.connectWithProvider) {
await wallet.connectWithProvider(rawProvider, address);
}
setXicReceiveAddress(address);
setShowWalletSelector(false);
toast.success(lang === "zh" ? `钱包已连接: ${address.slice(0, 6)}...${address.slice(-4)}` : `Wallet connected: ${address.slice(0, 6)}...${address.slice(-4)}`);
}}
/>
</div>
</div>,
document.body
)} </div>
);
}

View File

@ -479,6 +479,99 @@ export const appRouter = router({
return { success: true };
}),
// List bridge orders (cross-chain bridge)
listBridgeOrders: publicProcedure
.input(z.object({
token: z.string(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
status: z.enum(["all", "pending", "confirmed", "distributed", "failed"]).default("all"),
}))
.query(async ({ input }) => {
if (!input.token.startsWith("bmFjLWFkbWlu")) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
}
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" });
const offset = (input.page - 1) * input.limit;
let query = db.select().from(bridgeOrders);
if (input.status !== "all") {
query = query.where(eq(bridgeOrders.status, input.status)) as typeof query;
}
const rows = await query
.orderBy(desc(bridgeOrders.createdAt))
.limit(input.limit)
.offset(offset);
const countResult = await db
.select({ count: sql<number>`COUNT(*)` })
.from(bridgeOrders)
.where(input.status !== "all" ? eq(bridgeOrders.status, input.status) : sql`1=1`);
const intents = await db
.select()
.from(bridgeIntents)
.orderBy(desc(bridgeIntents.createdAt))
.limit(50);
return {
orders: rows.map(r => ({
id: r.id,
txHash: r.txHash,
walletAddress: r.walletAddress,
fromChainId: r.fromChainId,
fromToken: r.fromToken,
fromAmount: Number(r.fromAmount),
toChainId: r.toChainId,
toToken: r.toToken,
toAmount: Number(r.toAmount),
xicReceiveAddress: r.xicReceiveAddress,
status: r.status,
confirmedAt: r.confirmedAt,
distributedAt: r.distributedAt,
distributeTxHash: r.distributeTxHash,
blockNumber: r.blockNumber,
createdAt: r.createdAt,
})),
intents: intents.map(i => ({
id: i.id,
fromChainId: i.fromChainId,
senderAddress: i.senderAddress,
xicReceiveAddress: i.xicReceiveAddress,
expectedUsdt: i.expectedUsdt ? Number(i.expectedUsdt) : null,
matched: i.matched,
matchedOrderId: i.matchedOrderId,
createdAt: i.createdAt,
})),
total: Number(countResult[0]?.count || 0),
page: input.page,
limit: input.limit,
};
}),
// Update bridge order status (manual distribution)
updateBridgeOrder: publicProcedure
.input(z.object({
token: z.string(),
orderId: z.number().int(),
status: z.enum(["pending", "confirmed", "distributed", "failed"]),
distributeTxHash: z.string().optional(),
}))
.mutation(async ({ input }) => {
if (!input.token.startsWith("bmFjLWFkbWlu")) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
}
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" });
await db
.update(bridgeOrders)
.set({
status: input.status,
distributedAt: input.status === "distributed" ? new Date() : undefined,
distributeTxHash: input.distributeTxHash || null,
updatedAt: new Date(),
})
.where(eq(bridgeOrders.id, input.orderId));
return { success: true };
}),
// Test Telegram connection
testTelegram: publicProcedure
.input(z.object({

View File

@ -176,3 +176,12 @@
- [x] bridgeMonitor.ts更新所有链收款地址
- [x] Home.tsx更新TRC20收款地址为 TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp
- [x] contracts.ts同步更新TRC20/ERC20/BEP20地址
## v14 Bridge功能完善
- [x] 修复Bridge页面"连接钱包"按钮点击无效问题使用createPortal渲染到document.bodyz-index:9999
- [x] My Transactions列表增加USDT数量和XIC数量显示已在v13中完成
- [x] Confirm按钮点击后增加加载动画已有Loader2 animate-spin
- [x] 管理员后台添加Bridge订单管理页面Bridge Intents + Bridge Orders表格状态过滤手动标记分发
- [ ] 构建部署到AI服务器并测试
- [ ] 同步到备份Git库