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 Query(TanStack 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: Record<string, string> = {
investor: '/dashboard/investor',
issuer: '/dashboard/issuer',
compliance: '/dashboard/compliance',
};
const ROLE_ALLOWED_PREFIXES: Record<string, 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): Promise<AssetHolding[]> {
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: Record<number, 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 className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* 总资产卡片 */}
<Card>
<CardHeader className="text-sm text-muted-foreground">持仓总价值</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
${totalValue.toLocaleString('en-US', { minimumFractionDigits: 2 })}
</p>
</CardContent>
</Card>
{/* 风险敞口饼图 */}
<Card className="lg:col-span-2">
<CardHeader className="text-sm text-muted-foreground">风险构成</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
>
{pieData.map((entry, index) => (
<Cell key={index} fill={RISK_COLORS[entry.risk] || '#6b7280'} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* 持仓明细表 */}
<Card className="lg:col-span-3">
<CardContent className="pt-6">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-muted-foreground">
<th className="text-left py-2">资产</th>
<th className="text-right py-2">持仓量</th>
<th className="text-right py-2">公允价值</th>
<th className="text-right py-2">风险评级</th>
<th className="text-right py-2">占比</th>
</tr>
</thead>
<tbody>
{holdings.map((h) => (
<tr key={h.assetId} className="border-b last:border-0">
<td className="py-2 font-medium">{h.symbol}</td>
<td className="text-right">{h.balance.toLocaleString()}</td>
<td className="text-right">${h.fairValue.toLocaleString()}</td>
<td className="text-right">
<span
className="inline-block w-3 h-3 rounded-full mr-1 align-middle"
style={{ backgroundColor: RISK_COLORS[h.riskScore] }}
/>
{h.riskScore}
</td>
<td className="text-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] = useState<MultisigOperation | 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 className="space-y-4">
{/* 操作摘要 */}
<Card>
<CardContent className="pt-6">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-bold">
{operation.type === 'withdrawal' ? '提现' : '转账'} {operation.amount.toLocaleString()} {operation.asset}
</p>
<p className="text-sm text-muted-foreground">
操作 ID: {operation.operationId}
</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">
多签进度: {signedCount}/{operation.threshold}
</p>
<Progress value={(signedCount / operation.threshold) * 100} className="mt-1" />
</div>
</div>
</CardContent>
</Card>
{/* 签名方列表 */}
<div className="grid gap-2">
{operation.signers.map((signer) => (
<div
key={signer.address}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div>
<p className="font-medium text-sm">{signer.label}</p>
<p className="text-xs text-muted-foreground font-mono">
{signer.address.slice(0, 6)}...{signer.address.slice(-4)}
</p>
</div>
{signer.signed ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<Clock className="w-5 h-5 text-yellow-500" />
)}
</div>
))}
</div>
{/* 执行按钮 */}
{canExecute && (
<Button
className="w-full"
onClick={() => {
// 当收集到足够签名后,调用链上多签合约执行交易
writeContract({
address: '0xMultisigWallet',
abi: multisigAbi,
functionName: 'executeTransaction',
args: [operationId],
});
}}
disabled={isConfirming}
>
{isConfirming ? '链上确认中…' : '执行交易'}
</Button>
)}
{/* 交易状态 */}
{txHash && (
<p className="text-sm text-muted-foreground">
Tx: {txHash.slice(0, 10)}...{txHash.slice(-8)}
{isSuccess && <CheckCircle2 className="inline w-4 h-4 text-green-500 ml-1" />}
</p>
)}
</div>
);
}
四、边界与约束
实时性 vs 后台压力。React Query 的轮询间隔需要在数据新鲜度与 API 负载之间权衡。资产价格变化频繁但容忍 10-30 秒延迟是合理的,而多签状态变化需要更快响应。方案是分层设置 staleTime——价格数据 30s,KYC 状态 60s,操作流 3s。
WebSocket 连接的可靠性。多签状态推送依赖 WebSocket 持久连接,但企业内网代理、移动网络切换等都可能导致断连。必须实现指数退避重连 + 降级到轮询的双通道策略。
移动端适配。机构用户可能在移动端审批多签交易。仪表盘的复杂图表在手机屏幕上需要重新排版——饼图降级为水平条形图,表格改为卡片列表。shadcn/ui 的响应式工具类可以覆盖大部分场景,但 ECharts/Recharts 的移动适配需要额外处理 touch 事件与 viewport 缩放。
钱包连接持久化。Wagmi 的 useAccount 默认在页面刷新后丢失连接状态。多签场景中用户可能关闭浏览器、等待其他方签名数小时后再重新打开,此时需要依赖 localStorage 持久化 + autoConnect 恢复钱包会话。
五、总结
RWA 平台前端的复杂度不在于界面有多炫,而在于三类角色的信息不对称和操作时序的异步性如何被合理地封装为可预测的交互模式。
从 Middleware 的角色路由到 React Query 的按角色字段裁剪,再到多签操作流的轮询退避策略——每层的设计都在回应同一个问题:如何让不同权限的用户在同一个架构中各自获得恰好的信息密度和操作控制力,不多不少。RWA 资产代币化的前端,实质上是在 Web2 的企业权限管理(RBAC)与 Web3 的去中心化操作(多签/链上确认)之间搭建一座体验上一体化的桥梁。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_40635035/article/details/163175700



