48 小时极客原型:用 Viem 与 Tailwind 打造去中心化 DAO 投票治理全景看板

封面信息图

在去中心化自治组织(DAO)的日常治理中,投票治理(Governor Alpha / Bravo / OpenZeppelin Governor)是决定国库资金调拨与协议升级路线的核心机制。

然而,传统的 Snapshot 或 Tally 页面在面对复杂提案时,存在诸多交互痛点:

  • 无法直观看到当前赞成票(For)、反对票(Against)与弃权票(Abstain)在**法定门限(Quorum)**下的真实达成进度;
  • 缺乏对参与投票的巨鲸成员地址的实时穿透分析。

在上周末的 48 小时极客冲刺中,我利用 Viem 链上治理合约探针Next.js 14 App RouterTailwind CSS 赛博朋克深色仪表盘,从零构建了一套“全链去中心化 DAO 投票治理全景看板”。


一、DAO 治理看板全栈系统架构拓扑

graph TD
    ViemGovernor[Viem 治理探针: 读取 Governor 合约 state, proposalVotes, quorum] --> StateProcessor[数据清洗与 Quorum 达成率计算]
    
    subgraph Tailwind 赛博风治理看板
        StateProcessor --> QuorumBar[法定门限动态进度条 (Quorum Progress Bar)]
        StateProcessor --> VoteBreakdown[三色投票占比环形/柱状矩阵 (For/Against/Abstain)]
        StateProcessor --> WhaleList[巨鲸投票历史流水与 ENS 标签展示]
    end

    VoteBreakdown & QuorumBar --> ClientView[用户前端实时展示 (100% 声明式响应)]

二、DAO 治理链上数据读取与统计服务实现

// lib/daoGovernanceService.ts
import { createPublicClient, http, parseAbi, formatEther } from 'viem';
import { mainnet } from 'viem/chains';

const GOVERNOR_CONTRACT = '0xGovernorAddress...';

const governorAbi = parseAbi([
  'function state(uint256 proposalId) external view returns (uint8)',
  'function proposalVotes(uint256 proposalId) external view returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes)',
  'function quorum(uint256 blockNumber) external view returns (uint256)',
]);

export interface ProposalVoteStats {
  proposalId: string;
  title: string;
  status: 'PENDING' | 'ACTIVE' | 'CANCELED' | 'DEFEATED' | 'SUCCEEDED' | 'QUEUED' | 'EXECUTED';
  forVotes: number;
  againstVotes: number;
  abstainVotes: number;
  totalVotes: number;
  quorumNeeded: number;
  quorumReachedPct: number;
  forPct: number;
  againstPct: number;
}

export async function fetchProposalGovernanceDetails(proposalId: bigint): Promise<ProposalVoteStats> {
  const client = createPublicClient({ chain: mainnet, transport: http() });

  // 模拟从链上并行读取投票状态
  const forVotes = 1450000;
  const againstVotes = 320000;
  const abstainVotes = 50000;
  const quorumNeeded = 1200000;

  const total = forVotes + againstVotes + abstainVotes;
  const quorumPct = Math.min(100, Math.round((forVotes / quorumNeeded) * 100));

  return {
    proposalId: proposalId.toString(),
    title: 'CIP-42: 将协议国库 500,000 USDC 存入去中心化 AI 算力金库',
    status: 'ACTIVE',
    forVotes,
    againstVotes,
    abstainVotes,
    totalVotes: total,
    quorumNeeded,
    quorumReachedPct: quorumPct,
    forPct: Math.round((forVotes / total) * 100),
    againstPct: Math.round((againstVotes / total) * 100),
  };
}

三、React + Tailwind 赛博朋克治理看板界面实现

// components/DAOGovernanceHUD.tsx
'use client';

import React, { useEffect, useState } from 'react';
import { ProposalVoteStats, fetchProposalGovernanceDetails } from '@/lib/daoGovernanceService';

export function DAOGovernanceHUD() {
  const [stats, setStats] = useState<ProposalVoteStats | null>(null);

  useEffect(() => {
    fetchProposalGovernanceDetails(42n).then(setStats);
  }, []);

  if (!stats) return <div className="h-64 bg-slate-900 animate-pulse rounded-3xl" />;

  return (
    <div className="max-w-3xl mx-auto p-8 bg-slate-950 border border-slate-800 rounded-3xl text-white shadow-2xl">
      {/* 头部状态与标题 */}
      <div className="flex justify-between items-start mb-6">
        <div>
          <div className="flex items-center gap-2">
            <span className="text-[10px] px-2.5 py-0.5 bg-green-950 text-green-400 border border-green-500/40 rounded-full font-mono font-bold">
              {stats.status}
            </span>
            <span className="text-xs text-slate-400 font-mono">PROPOSAL #{stats.proposalId}</span>
          </div>
          <h2 className="text-xl font-bold text-slate-100 mt-2">{stats.title}</h2>
        </div>
      </div>

      {/* 法定门限达成度 Quorum 进度条 */}
      <div className="mb-8 p-5 bg-slate-900/80 border border-slate-800 rounded-2xl">
        <div className="flex justify-between items-center text-xs font-mono mb-2">
          <span className="text-slate-400">法定有效门限 (Quorum Progress)</span>
          <span className="text-cyan-300 font-bold">{stats.quorumReachedPct}% 已达成</span>
        </div>
        <div className="w-full bg-slate-950 rounded-full h-3 overflow-hidden border border-slate-800">
          <div
            className="bg-cyan-400 h-3 rounded-full transition-all duration-500 shadow-[0_0_12px_#00f3ff]"
            style={{ width: `${stats.quorumReachedPct}%` }}
          />
        </div>
      </div>

      {/* 赞成 vs 反对 三色投票柱状比例 */}
      <div className="space-y-4">
        {/* 赞成 For */}
        <div className="p-4 bg-slate-900/90 border border-green-500/30 rounded-2xl">
          <div className="flex justify-between items-center text-xs mb-2">
            <span className="text-green-400 font-bold flex items-center gap-1.5">
              <span>🟢</span> 赞成 (FOR)
            </span>
            <span className="font-mono text-slate-300 font-bold">{stats.forVotes.toLocaleString()} 票 ({stats.forPct}%)</span>
          </div>
          <div className="w-full bg-slate-950 rounded-full h-2 overflow-hidden">
            <div className="bg-green-500 h-2 rounded-full" style={{ width: `${stats.forPct}%` }} />
          </div>
        </div>

        {/* 反对 Against */}
        <div className="p-4 bg-slate-900/90 border border-red-500/30 rounded-2xl">
          <div className="flex justify-between items-center text-xs mb-2">
            <span className="text-red-400 font-bold flex items-center gap-1.5">
              <span>🔴</span> 反对 (AGAINST)
            </span>
            <span className="font-mono text-slate-300 font-bold">{stats.againstVotes.toLocaleString()} 票 ({stats.againstPct}%)</span>
          </div>
          <div className="w-full bg-slate-950 rounded-full h-2 overflow-hidden">
            <div className="bg-red-500 h-2 rounded-full" style={{ width: `${stats.againstPct}%` }} />
          </div>
        </div>
      </div>

      {/* 底部一键投票签名按钮 */}
      <div className="mt-8 pt-6 border-t border-slate-800 flex gap-4">
        <button className="flex-1 py-3 bg-green-600 hover:bg-green-500 rounded-xl font-bold text-xs transition">
          投赞成票 (Vote For)
        </button>
        <button className="flex-1 py-3 bg-red-600 hover:bg-red-500 rounded-xl font-bold text-xs transition">
          投反对票 (Vote Against)
        </button>
      </div>
    </div>
  );
}

四、48 小时极客原型调优复盘

  1. 快照区块(Snapshot Block)权重对齐:投票权不是根据当前余额计算,而是必须使用 getPastVotes(user, proposalSnapshotBlock) 读取提案创建瞬间的历史代币快照,杜绝闪电贷借币操控投票;
  2. 零布局偏移骨架屏支持:看板在初次加载时输出完全对齐的骨架结构,首屏加载极为丝滑。

用清晰直观的数据看板赋能去中心化民主治理,让社区共识在透明的数据流中凝聚。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐