Next.js 企业级 RWA 平台前端:KYC 集成、资产仪表盘与多签操作的交互设计
Next.js 企业级 RWA 平台前端KYC 集成、资产仪表盘与多签操作的交互设计一、引言RWA 平台的前端不同于 To C 的 DeFi dApp。资产发行方、机构投资者和合规官三类角色共享同一个入口但对数据可见性、操作权限和交互流程有截然不同的需求。一个机构投资者需要看到持仓组合的风险敞口、到期日瀑布流和税务明细一个合规官需要审批转账白名单、查看 KYC 状态变更日志、冻结可疑地址而资产发行方关注的是一级市场的认购进度和二级市场流动性数据。这些需求落到前端架构上意味着权限路由、数据分级展示与合规操作闭环三者必须从第一天就统一设计而非事后用if (role admin)到处打补丁。这篇文章以 Next.js App Router shadcn/ui 为技术栈拆解一个企业级 RWA 平台前端的交互设计方案基于角色的布局系统、实时资产仪表盘的数据架构、以及多签交易审批的交互闭环。二、核心原理整个前端架构可以按路由 → 数据 → 交互三层来拆解路由层使用 Next.js Middleware 做角色级别的页面访问控制而非在组件内判断。Middleware 阶段解析 JWT/钱包签名中的角色声明将三类用户路由到不同的默认仪表盘页。这比客户端路由守卫更安全因为在页面 JS 加载之前就已经完成了访问控制。数据层采用 React QueryTanStack Query管理服务端状态。资产数据通过 GraphQL 层聚合后按租户用户角色 所属组织分区返回同一 API 端点对不同角色返回不同的字段子集——合规官可以看到全量 KYC 数据而普通投资者只能看到自己的 KYC 状态。交互层的核心挑战是多签钱包操作的异步流程。一笔大额提现从发起 → 收集多签 → 广播交易 → 链上确认可能需要数分钟到数小时。前端不能用简单的 loading spinner 处理这个时间跨度需要引入操作流Operation Flow组件来展示多签收集进度、各签署方状态和最终交易的链上确认。架构的核心原则是角色感知的路由 租户隔离的数据查询。前端自身不做权限判定而是依赖后端 API 按请求携带的 JWT claims 来裁剪返回字段。这样即便前端被反编译也无法通过修改本地代码来绕过服务端的权限控制。三、关键实现Next.js Middleware 的角色路由实现// middleware.ts import { NextRequest, NextResponse } from next/server; import { jwtVerify } from jose; // 设计决策在 Middleware 中完成 JWT 验证与角色路由 // 避免 Layout / Page 组件中重复写角色判断逻辑 const ROLE_DEFAULT_PATHS: Recordstring, string { investor: /dashboard/investor, issuer: /dashboard/issuer, compliance: /dashboard/compliance, }; const ROLE_ALLOWED_PREFIXES: Recordstring, string[] { investor: [/dashboard/investor, /assets, /profile, /api], issuer: [/dashboard/issuer, /assets, /issuance, /api, /compliance], compliance: [/dashboard/compliance, /assets, /compliance, /api, /audit], }; export async function middleware(request: NextRequest) { const token request.cookies.get(auth_token)?.value; if (!token) { return NextResponse.redirect(new URL(/login, request.url)); } try { const { payload } await jwtVerify( token, new TextEncoder().encode(process.env.JWT_SECRET!) ); const role payload.role as string; const pathname request.nextUrl.pathname; // 根路径跳转到角色默认仪表盘 if (pathname / || pathname /dashboard) { const defaultPath ROLE_DEFAULT_PATHS[role] || /dashboard/investor; return NextResponse.redirect(new URL(defaultPath, request.url)); } // 检查当前路径是否在角色的允许前缀列表中 const allowed ROLE_ALLOWED_PREFIXES[role] || []; const hasAccess allowed.some((prefix) pathname.startsWith(prefix)); if (!hasAccess) { // 设计决策403 而非静默重定向让用户明确知道访问被拒绝 return new NextResponse(Forbidden, { status: 403 }); } return NextResponse.next(); } catch { return NextResponse.redirect(new URL(/login, request.url)); } } export const config { matcher: [/dashboard/:path*, /assets/:path*, /issuance/:path*, /compliance/:path*, /audit/:path*], };资产仪表盘组件——使用 React Query 的useSuspenseQuery实现声明式数据获取// components/dashboard/PortfolioOverview.tsx use client; import { useSuspenseQuery } from tanstack/react-query; import { Card, CardHeader, CardContent } from /components/ui/card; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from recharts; import { useAccount } from wagmi; interface AssetHolding { assetId: string; name: string; symbol: string; balance: number; fairValue: number; // 链上公允价 × 持仓数量 riskScore: number; // 1-10 maturityDate?: string; } // 设计决策fetch 函数独立导出便于 Server Components 复用 // 和 React Query 共享同一查询键体系 export async function fetchPortfolio(address: string): PromiseAssetHolding[] { const res await fetch(/api/portfolio/${address}, { headers: { Content-Type: application/json }, // 设计决策React Query 的 staleTime 设置 30s避免每次路由切换重新请求 next: { revalidate: 30 }, }); if (!res.ok) throw new Error(Failed to fetch portfolio); return res.json(); } const RISK_COLORS: Recordnumber, string { 1: #22c55e, 2: #84cc16, 3: #eab308, 4: #f97316, 5: #ef4444, 6: #dc2626, 7: #b91c1c, 8: #991b1b, 9: #7f1d1d, 10: #450a0a, }; export function PortfolioOverview() { const { address } useAccount(); // 设计决策使用悬停查询Suspense 边界数据未就绪时展示骨架而非 loading spinner const { data: holdings } useSuspenseQuery({ queryKey: [portfolio, address], queryFn: () fetchPortfolio(address!), staleTime: 30_000, }); const totalValue holdings.reduce((sum, h) sum h.fairValue, 0); const pieData holdings.map((h) ({ name: h.symbol, value: h.fairValue, risk: h.riskScore, })); return ( div classNamegrid grid-cols-1 lg:grid-cols-3 gap-4 {/* 总资产卡片 */} Card CardHeader classNametext-sm text-muted-foreground持仓总价值/CardHeader CardContent p classNametext-3xl font-bold ${totalValue.toLocaleString(en-US, { minimumFractionDigits: 2 })} /p /CardContent /Card {/* 风险敞口饼图 */} Card classNamelg:col-span-2 CardHeader classNametext-sm text-muted-foreground风险构成/CardHeader CardContent ResponsiveContainer width100% height{200} PieChart Pie data{pieData} dataKeyvalue nameKeyname cx50% cy50% outerRadius{80} {pieData.map((entry, index) ( Cell key{index} fill{RISK_COLORS[entry.risk] || #6b7280} / ))} /Pie Tooltip / /PieChart /ResponsiveContainer /CardContent /Card {/* 持仓明细表 */} Card classNamelg:col-span-3 CardContent classNamept-6 table classNamew-full text-sm thead tr classNameborder-b text-muted-foreground th classNametext-left py-2资产/th th classNametext-right py-2持仓量/th th classNametext-right py-2公允价值/th th classNametext-right py-2风险评级/th th classNametext-right py-2占比/th /tr /thead tbody {holdings.map((h) ( tr key{h.assetId} classNameborder-b last:border-0 td classNamepy-2 font-medium{h.symbol}/td td classNametext-right{h.balance.toLocaleString()}/td td classNametext-right${h.fairValue.toLocaleString()}/td td classNametext-right span classNameinline-block w-3 h-3 rounded-full mr-1 align-middle style{{ backgroundColor: RISK_COLORS[h.riskScore] }} / {h.riskScore} /td td classNametext-right {((h.fairValue / totalValue) * 100).toFixed(1)}% /td /tr ))} /tbody /table /CardContent /Card /div ); }多签操作流的交互组件// components/operations/MultisigFlow.tsx use client; import { useState, useEffect } from react; import { useWriteContract, useWaitForTransactionReceipt } from wagmi; import { Card, CardContent } from /components/ui/card; import { Button } from /components/ui/button; import { Progress } from /components/ui/progress; import { CheckCircle2, Clock, XCircle } from lucide-react; interface SignerStatus { address: string; label: string; // 合规官 A / 机构代表 B signed: boolean; timestamp?: number; } interface MultisigOperation { operationId: string; type: withdrawal | transfer | freeze; amount: number; asset: string; threshold: number; // 例如 3/5 多签 signers: SignerStatus[]; txHash?: string; status: pending | executing | confirmed | failed; } /** * 多签审批流组件。 * 设计决策使用轮询 WebSocket 双通道获取签名状态。 * 轮询间隔从 1s 指数退避到 10s减少服务器压力 * WebSocket 推送签名事件用于即时更新。 */ export function MultisigFlow({ operationId }: { operationId: string }) { const [operation, setOperation] useStateMultisigOperation | null(null); const { writeContract, data: txHash } useWriteContract(); const { isLoading: isConfirming, isSuccess } useWaitForTransactionReceipt({ hash: txHash, }); useEffect(() { const poll async () { const res await fetch(/api/operations/${operationId}); const data: MultisigOperation await res.json(); setOperation(data); }; poll(); const interval setInterval(poll, 3000); // 3s 轮询 return () clearInterval(interval); }, [operationId]); if (!operation) return null; const signedCount operation.signers.filter((s) s.signed).length; const canExecute signedCount operation.threshold operation.status pending; return ( div classNamespace-y-4 {/* 操作摘要 */} Card CardContent classNamept-6 div classNameflex justify-between items-center div p classNametext-lg font-bold {operation.type withdrawal ? 提现 : 转账} {operation.amount.toLocaleString()} {operation.asset} /p p classNametext-sm text-muted-foreground 操作 ID: {operation.operationId} /p /div div classNametext-right p classNametext-sm text-muted-foreground 多签进度: {signedCount}/{operation.threshold} /p Progress value{(signedCount / operation.threshold) * 100} classNamemt-1 / /div /div /CardContent /Card {/* 签名方列表 */} div classNamegrid gap-2 {operation.signers.map((signer) ( div key{signer.address} classNameflex items-center justify-between p-3 rounded-lg border div p classNamefont-medium text-sm{signer.label}/p p classNametext-xs text-muted-foreground font-mono {signer.address.slice(0, 6)}...{signer.address.slice(-4)} /p /div {signer.signed ? ( CheckCircle2 classNamew-5 h-5 text-green-500 / ) : ( Clock classNamew-5 h-5 text-yellow-500 / )} /div ))} /div {/* 执行按钮 */} {canExecute ( Button classNamew-full onClick{() { // 当收集到足够签名后调用链上多签合约执行交易 writeContract({ address: 0xMultisigWallet, abi: multisigAbi, functionName: executeTransaction, args: [operationId], }); }} disabled{isConfirming} {isConfirming ? 链上确认中… : 执行交易} /Button )} {/* 交易状态 */} {txHash ( p classNametext-sm text-muted-foreground Tx: {txHash.slice(0, 10)}...{txHash.slice(-8)} {isSuccess CheckCircle2 classNameinline w-4 h-4 text-green-500 ml-1 /} /p )} /div ); }四、边界与约束实时性 vs 后台压力。React Query 的轮询间隔需要在数据新鲜度与 API 负载之间权衡。资产价格变化频繁但容忍 10-30 秒延迟是合理的而多签状态变化需要更快响应。方案是分层设置 staleTime——价格数据 30sKYC 状态 60s操作流 3s。WebSocket 连接的可靠性。多签状态推送依赖 WebSocket 持久连接但企业内网代理、移动网络切换等都可能导致断连。必须实现指数退避重连 降级到轮询的双通道策略。移动端适配。机构用户可能在移动端审批多签交易。仪表盘的复杂图表在手机屏幕上需要重新排版——饼图降级为水平条形图表格改为卡片列表。shadcn/ui 的响应式工具类可以覆盖大部分场景但 ECharts/Recharts 的移动适配需要额外处理 touch 事件与 viewport 缩放。钱包连接持久化。Wagmi 的useAccount默认在页面刷新后丢失连接状态。多签场景中用户可能关闭浏览器、等待其他方签名数小时后再重新打开此时需要依赖localStorage持久化 autoConnect恢复钱包会话。五、总结RWA 平台前端的复杂度不在于界面有多炫而在于三类角色的信息不对称和操作时序的异步性如何被合理地封装为可预测的交互模式。从 Middleware 的角色路由到 React Query 的按角色字段裁剪再到多签操作流的轮询退避策略——每层的设计都在回应同一个问题如何让不同权限的用户在同一个架构中各自获得恰好的信息密度和操作控制力不多不少。RWA 资产代币化的前端实质上是在 Web2 的企业权限管理RBAC与 Web3 的去中心化操作多签/链上确认之间搭建一座体验上一体化的桥梁。