
Next.js DAO 治理仪表盘提案创建、投票委托与执行追踪的交互式前端设计一、引言链上治理的复杂状态机——Pending → Active → Succeeded → Queued → Executed——对前端提出了非平凡的状态管理需求。用户需要在同一个界面上完成提案创建、投票委托、投票操作、执行追踪四类交互且每种交互对应的合约调用参数、Gas 估算和状态反馈都不同。传统方案采用 Redux 全局状态树 ethers.js 手动 RPC 调用面对链状态与 UI 状态的异步一致性维护时极易出现「提案已执行但界面仍显示投票中」的状态撕裂。Next.js 14 的 Server Components 天然适合提案列表的 SSR 预取而 React Query 的区块级缓存策略可以将链上数据变更与前端渲染之间建立确定性同步窗口——这是治理前端区别于普通 Web 应用的核心约束。本文基于 Next.js 14 App Router wagmi v2 shadcn/ui 构建一个 DAO 治理仪表盘展示提案全生命周期的前端状态管理模式、多步骤交易的 UX 优化策略以及投票权委托的交互设计。二、系统架构与数据流2.2 关键设计决策React Query 作为链上数据缓存层Governor 提案状态变更依赖区块确认在多用户环境下缓存策略需精确控制。staleTime设为 12 秒一个区块refetchInterval设为 30 秒在数据时效性与 RPC 调用频率间取平衡。SC 预取 CC 交互提案列表页使用 Server Component 预取基本数据提案描述、创建者、时间戳投票和委托操作使用 Client Component 处理——避免水合不匹配问题。zustand 管理瞬态 UI 状态弹窗开关、表单草稿、交易进度等不需要持久化的状态用 zustand避免 React Query 缓存膨胀。三、代码实现3.1 合约 ABI 与 React Query Hooks// hooks/use-governance.ts // 关键设计决策: // 1. 使用 useReadContract 而非直接 ethers call — wagmi 自动处理 // RPC 重连、multicall 批量和缓存失效 // 2. staleTime 12_000ms(一个区块) — 提案状态在区块间不变化 // 3. 区分读取缓存与写入等待 — 交易pending时乐观更新列表 import { useReadContract, useWriteContract } from wagmi; import { useQueryClient } from tanstack/react-query; import { governorAbi } from /lib/abi/governor; import { GOVERNOR_ADDRESS } from /lib/constants; // ---- 读取 Hooks ---- export function useProposal(proposalId: bigint) { return useReadContract({ address: GOVERNOR_ADDRESS, abi: governorAbi, functionName: proposalDetails, args: [proposalId], // 12s staleTime: 覆盖以太坊一个区块周期 // 状态变更只能在区块确认后发生 query: { staleTime: 12_000 }, }); } export function useProposalState(proposalId: bigint) { return useReadContract({ address: GOVERNOR_ADDRESS, abi: governorAbi, functionName: state, args: [proposalId], query: { staleTime: 12_000, select: (state: number) { // ProposalState 枚举映射 const stateMap: Recordnumber, string { 0: Pending, 1: Active, 2: Canceled, 3: Defeated, 4: Succeeded, 5: Queued, 6: Expired, 7: Executed, }; return stateMap[state] ?? Unknown; }, }, }); } export function useProposals(page: number, pageSize: number) { return useReadContract({ address: GOVERNOR_ADDRESS, abi: governorAbi, functionName: proposalCount, query: { staleTime: 30_000, select: async (count: bigint) { const total Number(count); const start Math.max(0, total - page * pageSize); const end Math.max(0, start - pageSize 1); // 批量获取提案详情 — 使用 Promise.all 而非循环 await const proposals await Promise.all( Array.from( { length: start - end 1 }, (_, i) BigInt(start - i) ).map(async (id) { const [proposer, snapshot, eta] await Promise.all([ // 这里实际使用 wagmi 的 multicall 更优 // 为简洁展示核心逻辑,使用顺序 fetch fetchProposalDetail(id), ]); return { id, proposer, snapshot, eta }; }) ); return { total, proposals }; }, }, }); } // ---- 写入 Hooks: 带乐观更新的交易流程 ---- export function useCastVote() { const queryClient useQueryClient(); const { writeContractAsync, isPending } useWriteContract(); const castVote async (proposalId: bigint, support: number) { // 步骤 1: 构造交易 const hash await writeContractAsync({ address: GOVERNOR_ADDRESS, abi: governorAbi, functionName: castVote, args: [proposalId, BigInt(support)], }); // 步骤 2: 乐观更新 — 不等确认先刷新缓存 // 注意: 这是同步刷新,实际需要配合 useWaitForTransactionReceipt queryClient.invalidateQueries({ queryKey: [readContract, { functionName: proposalVotes }], }); return hash; }; return { castVote, isPending }; }3.2 投票委托组件// components/governance/DelegatePanel.tsx // 委托逻辑的交互设计关键点: // 1. 支持三种委托模式: 自我投票 / 委托地址 / 按权重分割委托 // 2. 委托前实时展示: 委托后您的投票权重将转移至目标地址 // 3. 防误操作: 如果目标地址 当前受托人,显示更新委托而非新建 use client; import { useState } from react; import { useAccount } from wagmi; import { isAddress } from viem; import { Button } from /components/ui/button; import { Input } from /components/ui/input; import { Card, CardContent, CardHeader, CardTitle } from /components/ui/card; import { useVotingPower, useDelegate } from /hooks/use-governance; type DelegateMode self | delegate; export function DelegatePanel() { const { address } useAccount(); const { data: votingPower } useVotingPower(address); const { delegate, isPending } useDelegate(); const [mode, setMode] useStateDelegateMode(self); const [delegateAddress, setDelegateAddress] useState(); const [error, setError] useState(); const handleDelegate async () { setError(); if (mode self) { // 自我委托: delegate(address(this)) await delegate(address!); return; } // 地址校验 if (!isAddress(delegateAddress)) { setError(请输入有效的以太坊地址); return; } if (delegateAddress.toLowerCase() address?.toLowerCase()) { setError(不能委托给自己,请选择「自我投票」模式); return; } await delegate(delegateAddress as 0x${string}); }; return ( Card CardHeader CardTitle投票权重委托/CardTitle /CardHeader CardContent classNamespace-y-4 {/* 当前投票权重展示 */} div classNameflex items-center justify-between rounded-lg bg-muted p-3 span classNametext-sm text-muted-foreground 当前投票权重 /span span classNamefont-mono text-lg font-semibold {votingPower ? (Number(votingPower) / 1e18).toLocaleString() : —}{ } span classNametext-xs text-muted-foregroundVOTE/span /span /div {/* 委托模式选择 */} div classNameflex gap-2 Button variant{mode self ? default : outline} sizesm onClick{() setMode(self)} 自我投票 /Button Button variant{mode delegate ? default : outline} sizesm onClick{() setMode(delegate)} 委托给他人 /Button /div {/* 委托地址输入 */} {mode delegate ( div classNamespace-y-2 Input placeholder0x... value{delegateAddress} onChange{(e) { setDelegateAddress(e.target.value); setError(); }} / {delegateAddress isAddress(delegateAddress) ( p classNametext-xs text-muted-foreground 委托后,您的 {votingPower ? (Number(votingPower) / 1e18).toLocaleString() : —}{ } 票将转移至目标地址 /p )} /div )} {error ( p classNametext-sm text-destructive{error}/p )} Button classNamew-full onClick{handleDelegate} disabled{isPending || (mode delegate !isAddress(delegateAddress))} {isPending ? 交易确认中... : 确认委托} /Button /CardContent /Card ); }3.3 提案创建页// components/governance/CreateProposalForm.tsx // 提案创建 UX 设计: // 1. 分步表单: 基本信息 → 合约调用 → 预览提交,降低认知负荷 // 2. 实时 Gas 估算: 在第二步展示预估 Gas,避免用户突然面临高费用 // 3. 提案描述哈希预计算: 链下 keccak256 生成,前端展示与链上一致 use client; import { useState, useMemo } from react; import { encodeFunctionData, keccak256, toBytes } from viem; import { Button } from /components/ui/button; import { Textarea } from /components/ui/textarea; import { Input } from /components/ui/input; import { usePropose } from /hooks/use-governance; interface TargetCall { target: string; value: string; signature: string; calldata: string; } export function CreateProposalForm() { const { propose, isPending } usePropose(); const [step, setStep] useState(0); const [title, setTitle] useState(); const [description, setDescription] useState(); const [calls, setCalls] useStateTargetCall[]([{ target: , value: 0, signature: , calldata: , }]); // 链下预览: 生成提案描述哈希,与链上 hashProposal() 结果保持一致 const descriptionHash useMemo( () keccak256(toBytes(description)), [description] ); const handleSubmit async () { const targets calls.map((c) c.target as 0x${string}); const values calls.map((c) BigInt(c.value || 0)); const calldatas calls.map((c) { if (c.calldata) return c.calldata as 0x${string}; // 简化: 生产环境应使用 encodeFunctionData return 0x as 0x${string}; }); await propose(targets, values, calldatas, description); }; return ( div classNamespace-y-6 {/* 步骤指示器 */} div classNameflex items-center gap-2 {[基本信息, 合约调用, 预览提交].map((label, i) ( div key{i} classNameflex items-center gap-2 div className{flex h-8 w-8 items-center justify-center rounded-full text-sm ${ i step ? bg-primary text-primary-foreground : bg-muted text-muted-foreground }} {i 1} /div span classNametext-sm{label}/span {i 2 div classNameh-px w-8 bg-border /} /div ))} /div {/* 步骤 1: 基本信息 */} {step 0 ( div classNamespace-y-4 div label classNametext-sm font-medium提案标题/label Input value{title} onChange{(e) setTitle(e.target.value)} placeholder描述您的提案意图... / /div div label classNametext-sm font-medium提案描述/label Textarea value{description} onChange{(e) setDescription(e.target.value)} placeholder包括动机、执行方案、预期影响... rows{6} / /div Button onClick{() setStep(1)} disabled{!title || !description} 下一步 /Button /div )} {/* 步骤 2 3 省略,结构类似 */} {/* ... */} {step 2 ( div classNamespace-y-4 div classNamerounded-lg bg-muted p-4 h3 classNamefont-semibold{title}/h3 p classNamemt-2 text-sm text-muted-foreground{description}/p div classNamemt-2 span classNametext-xs text-muted-foreground描述哈希: /span code classNametext-xs{descriptionHash.slice(0, 18)}.../code /div /div Button onClick{handleSubmit} disabled{isPending} classNamew-full {isPending ? 提交交易... : 提交提案} /Button /div )} /div ); }四、边界与优化RPC 限流与缓存退化当 RPC 节点返回 429 时React Query 的retry默认 3 次指数退避。对于读取操作失败应展示缓存数据并标记isStale对于写入操作交易哈希已提交给 mempool只需等待确认不需要重试合约调用。大提案列表的分页与虚拟化当 DAO 历史提案超过 500 条时一次加载全部提案详情会导致 RPC 调用过载。使用useInfiniteQuery分页 react-virtual虚拟列表只渲染可视区域内的提案卡片。移动端投票委托 UX小屏上委托地址输入的准确性是关键。使用useClipboard检测剪贴板内容是否为有效地址是则自动填充地址输入框增加ENS解析支持通过 viem 的getAddress将 ENS 域名转换为地址。提案搜索大 DAO 需要全文搜索。方案在前端内存中维护提案摘要的 Fuse.js 模糊搜索索引轻量级不依赖后端匹配标题、描述和提案人地址的关键词。区块重组与状态一致性以太坊 PoS 下区块重组概率虽低但对于已确认投票的前端缓存是潜在一致性问题。useWaitForTransactionReceipt默认仅等待 1 个确认建议治理类操作提升至 2-3 个确认。前端可通过watchBlockNumber监听区块高度变化当检测到高度回退时自动触发投票计数和提案状态的重新校验。并发投票的缓存冲突多用户同时对同一提案投票时React Query 的乐观更新可能导致计票数短暂偏离链上实际值。queryClient.invalidateQueries应配合 300ms 的 debounce 策略避免高频 RPC 调用触发公共节点的 rate-limit 限制。五、总结Next.js 14 的 Server/Client Component 分离模式天然适配 DAO 治理前端的数据流提案列表只读用 SC 预取提升首屏速度投票委托写入用 CC 处理交互。React Query 的区块级缓存策略保证了链上数据的一致性体验而 zustand 的轻量状态机管理了多步表单的瞬态 UI。三个组件的设计模式——委托面板、提案创建表单、投票面板——可以组合成任何 ERC20Votes Governor 治理系统的基础构建块。