dApp Docs/链上治理dApp完整前端实现
Development reference. Not independently verified for production.

MSG Chain 链上治理 dApp 完整前端实现指南

版本:v1.0 | 主网状态:No-Go | 链:msg-chain-1 | Bech32 前缀:msg


目录

  1. 概述
  2. 提案类型与数据结构
  3. 提案列表頁
  4. 提案詳情頁
  5. 提案創建
  6. 投票系統
  7. 治理參數顯示
  8. DAO提案管理
  9. 完整示例
  10. 部署指南

1. 概述

1.1 什么是链上治理 dApp

链上治理 dApp(Decentralized Governance Application)是一个去中心化前端应用,允许用户通过区块链网络参与协议的治理决策。在 MSG Chain 上,治理 dApp 连接了 Cosmos SDK x/gov 模块(第一层链治理)和 dao_governance_v1 智能合约(第二层 DAO 治理),为用户提供统一的界面进行提案浏览、投票、创建和执行。

用户浏览器
    |
    ├── Keplr 钱包 ─────→ MSG Chain (msg-chain-1)
    |                          |
    |                    ┌─────┴─────┐
    |                    |           |
    |              x/gov 模块    dao_governance_v1
    |           (链治理)         (DAO 合约治理)
    |                    |           |
    |                    └─────┬─────┘
    |                          |
    └──────────────────→ foundation_treasury_v2
                        (金库多签)

1.2 核心功能特性

功能 描述 后端来源
提案列表 浏览所有链上提案 x/gov REST API + CosmWasm Query
提案详情 查看单个提案完整元数据 x/gov + DAO 合约
投票 对活跃提案进行投票 SigningClient execute
提案创建 提交新提案 不同类型不同消息结构
计票统计 可视化投票结果 Recharts 图表组件
参数查看 治理模块参数一览 REST API + 合约 Query
DAO 多签提案 金库支出提案管理 DAO 合约 + Treasury 合约

1.3 技术栈选择

React 18        → UI 框架
TypeScript      → 类型安全
@cosmjs/*       → Cosmos 链交互
@cosmjs/cosmwasm-stargate → 合约交互
@cosmjs/stargate          → SDK 链查询
@cosmjs/proto-signing     → 交易签名

Recharts        → 图表可视化
CSS Modules     → 样式方案
Vite            → 构建工具

1.4 项目初始化

npm create vite@latest governance-dapp -- --template react-ts
cd governance-dapp
npm install @cosmjs/cosmwasm-stargate @cosmjs/stargate @cosmjs/proto-signing
npm install @cosmjs/amino
npm install recharts react-router-dom
npm install @keplr-wallet/types

1.5 项目结构

src/
|-- main.tsx
|-- App.tsx
|-- index.css
|-- types/
|   |-- governance.ts          # Cosmos SDK x/gov 类型
|   |-- dao.ts                 # DAO 合约类型
|   |-- treasury.ts            # 金库合约类型
|-- config/
|   |-- chain.ts               # 链配置
|-- context/
|   |-- WalletContext.tsx       # 钱包连接上下文
|   |-- DaoContext.tsx          # DAO 合约上下文
|-- hooks/
|   |-- useProposals.ts        # 提案查询 Hook
|   |-- useVote.ts             # 投票 Hook
|   |-- useGovernanceParams.ts # 治理参数 Hook
|-- components/
|   |-- Layout.tsx
|   |-- ConnectWallet.tsx
|   |-- ProposalList/
|   |   |-- ProposalList.tsx
|   |   |-- ProposalCard.tsx
|   |   |-- FilterBar.tsx
|   |   |-- SearchBar.tsx
|   |   |-- ProposalList.css
|   |-- ProposalDetail/
|   |   |-- ProposalDetail.tsx
|   |   |-- VoteTallyChart.tsx
|   |   |-- Timeline.tsx
|   |   |-- VoterList.tsx
|   |   |-- ProposalDetail.css
|   |-- ProposalForm/
|   |   |-- CreateProposal.tsx
|   |   |-- TextProposalForm.tsx
|   |   |-- ParamChangeForm.tsx
|   |   |-- SpendForm.tsx
|   |   |-- UpgradeForm.tsx
|   |   |-- DepositSelector.tsx
|   |   |-- ProposalForm.css
|   |-- VotePanel/
|   |   |-- VotePanel.tsx
|   |   |-- VoteConfirmModal.tsx
|   |   |-- VoteStatusBadge.tsx
|   |   |-- VotePanel.css
|   |-- GovernanceParams/
|   |   |-- GovernanceParams.tsx
|   |   |-- GovernanceParams.css
|   |-- DaoProposals/
|       |-- DaoProposalList.tsx
|       |-- DaoProposalDetail.tsx
|       |-- TreasuryDashboard.tsx
|       |-- DaoProposals.css
|-- pages/
|   |-- HomePage.tsx
|   |-- ProposalsPage.tsx
|   |-- ProposalDetailPage.tsx
|   |-- CreateProposalPage.tsx
|   |-- GovernanceParamsPage.tsx
|   |-- DaoPage.tsx
|-- utils/
    |-- format.ts
    |-- cosmosQueries.ts
    |-- daoQueries.ts


2. 提案类型与数据结构

2.1 Cosmos SDK x/gov 提案类型

MSG Chain 的 x/gov 模块支持四种原生提案类型,每种类型通过 @type 字段区分。

2.1.1 TextProposal(文本提案)

纯信号提案,无链上执行动作。

interface TextProposalContent {
  "@type": "/cosmos.gov.v1beta1.TextProposal";
  title: string;
  description: string;
}

2.1.2 ParameterChangeProposal(参数变更提案)

修改链上模块参数。

interface ParamChange {
  subspace: string;  // 模块名,如 "gov"、"staking"
  key: string;       // 参数名,如 "voting_period"
  value: string;     // JSON 编码的值
}

interface ParamChangeProposalContent {
  "@type": "/cosmos.gov.v1beta1.ParameterChangeProposal";
  title: string;
  description: string;
  changes: ParamChange[];
}

2.1.3 SoftwareUpgradeProposal(软件升级提案)

触发链上软件升级。

interface UpgradePlan {
  name: string;    // 升级名称,如 "v1.1.0"
  height: number;  // 升级高度
  info: string;    // 升级信息(二进制哈希等)
}

interface SoftwareUpgradeProposalContent {
  "@type": "/cosmos.gov.v1beta1.SoftwareUpgradeProposal";
  title: string;
  description: string;
  plan: UpgradePlan;
}

2.1.4 CommunityPoolSpendProposal(社区池支出提案)

从社区池拨款。

interface Coin {
  denom: string;
  amount: string;
}

interface CommunityPoolSpendProposalContent {
  "@type": "/cosmos.gov.v1beta1.CommunityPoolSpendProposal";
  title: string;
  description: string;
  recipient: string;
  amount: Coin[];
}

2.2 DAO 合约提案类型

2.2.1 SubmitProposal 标准提案

DAO 合约的标准提案,包含标题、描述和可选的合约执行消息。

interface DaoSubmitProposalMsg {
  submit_proposal: {
    title: string;
    description: string;
    target_contract?: string | null;
    execute_msg?: string | null;
    deposit_amount?: string | null;
  };
}

interface DaoProposal {
  id: number;
  title: string;
  description: string;
  proposer: string;
  status: "active" | "passed" | "rejected" | "executed" | "canceled";
  created_at: number;
  voting_start: number | null;
  voting_end: number | null;
  target_contract: string | null;
  execute_msg: string | null;
  deposit_amount: string;
}

2.2.2 TreasuryProposal(金库支出提案)

金库合约的支出提案,用于多签管理资金。

interface TreasuryProposal {
  id: number;
  recipient: string;
  amount: string;
  denom: string;
  purpose: string;
  status: "pending" | "approved" | "executed" | "rejected";
  approvers: string[];
  created_at: number;
}

2.3 统一提案联合类型

type ProposalType =
  | "text"
  | "parameter_change"
  | "software_upgrade"
  | "community_spend"
  | "dao_generic"
  | "dao_contract_exec"
  | "treasury_spend";

type ProposalContent =
  | TextProposalContent
  | ParamChangeProposalContent
  | SoftwareUpgradeProposalContent
  | CommunityPoolSpendProposalContent;

interface CosmosProposal {
  proposal_id: string;
  content: ProposalContent;
  status: CosmosProposalStatus;
  final_tally_result: TallyResult;
  submit_time: string;
  deposit_end_time: string;
  voting_start_time: string;
  voting_end_time: string;
  total_deposit: Coin[];
}

interface TallyResult {
  yes: string;
  abstain: string;
  no: string;
  no_with_veto: string;
}

type CosmosProposalStatus =
  | "PROPOSAL_STATUS_UNSPECIFIED"
  | "PROPOSAL_STATUS_DEPOSIT_PERIOD"
  | "PROPOSAL_STATUS_VOTING_PERIOD"
  | "PROPOSAL_STATUS_PASSED"
  | "PROPOSAL_STATUS_REJECTED"
  | "PROPOSAL_STATUS_FAILED";

type VoteOption =
  | "VOTE_OPTION_YES"
  | "VOTE_OPTION_NO"
  | "VOTE_OPTION_ABSTAIN"
  | "VOTE_OPTION_NO_WITH_VETO";

type DaoVoteOption = "yes" | "no" | "abstain" | "veto";

2.4 提案类型检测工具

function getProposalType(content: ProposalContent): ProposalType {
  switch (content["@type"]) {
    case "/cosmos.gov.v1beta1.TextProposal":
      return "text";
    case "/cosmos.gov.v1beta1.ParameterChangeProposal":
      return "parameter_change";
    case "/cosmos.gov.v1beta1.SoftwareUpgradeProposal":
      return "software_upgrade";
    case "/cosmos.gov.v1beta1.CommunityPoolSpendProposal":
      return "community_spend";
    default:
      return "text";
  }
}

function getProposalTypeLabel(type: ProposalType): string {
  const labels: Record<ProposalType, string> = {
    text: "文本提案",
    parameter_change: "参数修改",
    software_upgrade: "软件升级",
    community_spend: "社区支出",
    dao_generic: "DAO 通用提案",
    dao_contract_exec: "合约执行提案",
    treasury_spend: "金库支出提案",
  };
  return labels[type];
}

function getProposalStatusLabel(status: string): string {
  const labels: Record<string, string> = {
    PROPOSAL_STATUS_DEPOSIT_PERIOD: "保证金期",
    PROPOSAL_STATUS_VOTING_PERIOD: "投票中",
    PROPOSAL_STATUS_PASSED: "已通过",
    PROPOSAL_STATUS_REJECTED: "已拒绝",
    PROPOSAL_STATUS_FAILED: "失败",
    active: "投票中",
    passed: "已通过",
    rejected: "已拒绝",
    executed: "已执行",
    canceled: "已取消",
    pending: "待批准",
    approved: "已批准",
  };
  return labels[status] || status;
}

function getStatusColor(status: string): string {
  const colors: Record<string, string> = {
    PROPOSAL_STATUS_DEPOSIT_PERIOD: "#FF9800",
    PROPOSAL_STATUS_VOTING_PERIOD: "#2196F3",
    PROPOSAL_STATUS_PASSED: "#4CAF50",
    PROPOSAL_STATUS_REJECTED: "#F44336",
    PROPOSAL_STATUS_FAILED: "#9E9E9E",
    active: "#2196F3",
    passed: "#4CAF50",
    rejected: "#F44336",
    executed: "#9E9E9E",
    canceled: "#FF9800",
    pending: "#FF9800",
    approved: "#4CAF50",
  };
  return colors[status] || "#9E9E9E";
}

2.5 链配置

// config/chain.ts
export const CHAIN_CONFIG = {
  chainId: "msg-chain-1",
  chainName: "MSG Chain",
  rpcEndpoint: "https://rpc.msgchain.org",
  restEndpoint: "https://api.msgchain.org",
  prefix: "msg",
  denom: "umsg",
  symbol: "MSG",
  decimals: 6,
  gasPrice: "1000000000attoMSG",
  gasAdjustment: 1.5,
  coinType: 118,
};

export const CONTRACT_ADDRESSES = {
  genesisRegistry: "msg1registryaddressplaceholder...",
};

export const PAGE_SIZE = 20;

3. 提案列表頁

3.1 数据类型定义

// types/governance.ts
export interface ProposalListProps {
  status: "all" | "voting" | "passed" | "rejected" | "executed";
  onSelect: (id: number) => void;
  source: "cosmos" | "dao";
}

3.2 Cosmos 提案查询 Hook

// hooks/useProposals.ts
import { useState, useEffect, useCallback } from "react";
import { CHAIN_CONFIG, PAGE_SIZE } from "../config/chain";

const STATUS_MAP: Record<string, string> = {
  all: "",
  voting: "PROPOSAL_STATUS_VOTING_PERIOD",
  passed: "PROPOSAL_STATUS_PASSED",
  rejected: "PROPOSAL_STATUS_REJECTED",
  executed: "",
};

export function useCosmosProposals(
  status: string,
  restEndpoint: string = CHAIN_CONFIG.restEndpoint
) {
  const [proposals, setProposals] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);

  const fetchProposals = useCallback(async (pageNum: number) => {
    setLoading(true);
    setError(null);
    try {
      let url = `${restEndpoint}/cosmos/gov/v1beta1/proposals`
        + `?pagination.limit=${PAGE_SIZE}`
        + `&pagination.offset=${(pageNum - 1) * PAGE_SIZE}`;
      const mappedStatus = STATUS_MAP[status];
      if (mappedStatus) url += `&status=${mappedStatus}`;
      const res = await fetch(url);
      const data = await res.json();
      const newProposals = data.proposals || [];
      setProposals(prev =>
        pageNum === 1 ? newProposals : [...prev, ...newProposals]
      );
      setHasMore(newProposals.length === PAGE_SIZE);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [status, restEndpoint]);

  useEffect(() => {
    setPage(1);
    fetchProposals(1);
  }, [fetchProposals]);

  const loadMore = useCallback(() => {
    if (!loading && hasMore) {
      const nextPage = page + 1;
      setPage(nextPage);
      fetchProposals(nextPage);
    }
  }, [loading, hasMore, page, fetchProposals]);

  const refresh = useCallback(() => {
    setPage(1);
    fetchProposals(1);
  }, [fetchProposals]);

  return { proposals, loading, error, loadMore, hasMore, refresh };
}

3.3 DAO 提案查询 Hook

export function useDaoProposals(
  status: string,
  client: any,
  daoAddress: string | null
) {
  const [proposals, setProposals] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!client || !daoAddress) return;
    let cancelled = false;

    async function fetch() {
      setLoading(true);
      try {
        const queryMsg: any = { list_proposals: { limit: 50 } };
        if (status !== "all") {
          queryMsg.list_proposals.status = status;
        }
        const result: any = await client.queryContractSmart(
          daoAddress,
          queryMsg
        );
        if (!cancelled) setProposals(result.proposals || []);
      } catch (err: any) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }
    fetch();
    return () => { cancelled = true; };
  }, [client, daoAddress, status]);

  return { proposals, loading, error };
}

3.4 ProposalCard 组件

// components/ProposalList/ProposalCard.tsx
import React from "react";
import {
  getProposalTypeLabel,
  getStatusColor,
  getProposalStatusLabel,
} from "../../types/governance";

interface ProposalCardProps {
  proposal: any;
  onClick: () => void;
  source: "cosmos" | "dao";
}

function truncateAddress(addr: string): string {
  if (!addr) return "";
  return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
}

function formatAmount(amount: string, denom: string = "umsg"): string {
  const val = Number(amount) / 1_000_000;
  return (
    val.toLocaleString("zh-CN", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 6,
    }) +
    ` ${denom.replace("u", "").toUpperCase()}`
  );
}

function getTimeAgo(timestamp: string | number): string {
  const now = Date.now();
  const time =
    typeof timestamp === "string"
      ? new Date(timestamp).getTime()
      : timestamp * 1000;
  const diff = now - time;
  const hours = Math.floor(diff / 3600000);
  if (hours < 1) return "刚刚";
  if (hours < 24) return `${hours}小时前`;
  const days = Math.floor(hours / 24);
  if (days < 30) return `${days}天前`;
  return `${Math.floor(days / 30)}月前`;
}

export function ProposalCard({ proposal, onClick, source }: ProposalCardProps) {
  const isCosmos = source === "cosmos";
  const id = isCosmos ? Number(proposal.proposal_id) : proposal.id;
  const title = isCosmos ? proposal.content?.title : proposal.title;
  const description = isCosmos
    ? proposal.content?.description
    : proposal.description;
  const status = proposal.status;
  const proposer = isCosmos ? "" : proposal.proposer;
  const type = isCosmos
    ? getProposalTypeLabel("text")
    : proposal.target_contract
      ? "合约执行提案"
      : "DAO 通用提案";

  return (
    <div className="proposal-card" onClick={onClick}>
      <div className="proposal-card-header">
        <span className="proposal-id">#{id}</span>
        <span
          className="status-badge"
          style={{ backgroundColor: getStatusColor(status) }}
        >
          {getProposalStatusLabel(status)}
        </span>
        <span className="proposal-type">{type}</span>
      </div>

      <h3 className="proposal-card-title">{title}</h3>

      <p className="proposal-card-desc">
        {description && description.length > 120
          ? description.slice(0, 120) + "..."
          : description}
      </p>

      <div className="proposal-card-meta">
        {proposer && (
          <span className="meta-item">
            <span className="meta-label">提案人</span>
            <span className="meta-value">
              {truncateAddress(proposer)}
            </span>
          </span>
        )}
        {isCosmos && proposal.submit_time && (
          <span className="meta-item">
            <span className="meta-label">提交</span>
            <span className="meta-value">
              {getTimeAgo(proposal.submit_time)}
            </span>
          </span>
        )}
        {!isCosmos && proposal.created_at && (
          <span className="meta-item">
            <span className="meta-label">提交</span>
            <span className="meta-value">
              {getTimeAgo(proposal.created_at)}
            </span>
          </span>
        )}
      </div>

      {isCosmos && proposal.final_tally_result && (
        <div className="mini-tally">
          <TallyMiniBar tally={proposal.final_tally_result} />
        </div>
      )}
    </div>
  );
}

function TallyMiniBar({
  tally,
}: {
  tally: { yes: string; no: string; abstain: string; no_with_veto: string };
}) {
  const total = [tally.yes, tally.no, tally.abstain, tally.no_with_veto].reduce(
    (sum, v) => sum + Number(v),
    0
  );
  if (total === 0) return <div className="mini-tally-empty">暂无投票</div>;

  const yesPct = (Number(tally.yes) / total) * 100;
  const noPct = (Number(tally.no) / total) * 100;
  const vetoPct = (Number(tally.no_with_veto) / total) * 100;
  const abstainPct = (Number(tally.abstain) / total) * 100;

  return (
    <div className="mini-tally-bar">
      {yesPct > 0 && (
        <div
          className="mini-bar-segment yes"
          style={{ width: `${yesPct}%` }}
          title={`赞成: ${formatAmount(tally.yes)}`}
        />
      )}
      {noPct > 0 && (
        <div
          className="mini-bar-segment no"
          style={{ width: `${noPct}%` }}
          title={`反对: ${formatAmount(tally.no)}`}
        />
      )}
      {abstainPct > 0 && (
        <div
          className="mini-bar-segment abstain"
          style={{ width: `${abstainPct}%` }}
          title={`弃权: ${formatAmount(tally.abstain)}`}
        />
      )}
      {vetoPct > 0 && (
        <div
          className="mini-bar-segment veto"
          style={{ width: `${vetoPct}%` }}
          title={`否决: ${formatAmount(tally.no_with_veto)}`}
        />
      )}
    </div>
  );
}

3.5 FilterBar 组件

// components/ProposalList/FilterBar.tsx
import React from "react";

interface FilterBarProps {
  activeFilter: string;
  onFilterChange: (filter: string) => void;
  source: "cosmos" | "dao";
}

const COSMOS_FILTERS = [
  { key: "all", label: "全部" },
  { key: "voting", label: "投票中" },
  { key: "passed", label: "已通过" },
  { key: "rejected", label: "已拒绝" },
];

const DAO_FILTERS = [
  { key: "all", label: "全部" },
  { key: "active", label: "投票中" },
  { key: "passed", label: "已通过" },
  { key: "executed", label: "已执行" },
  { key: "canceled", label: "已取消" },
];

export function FilterBar({
  activeFilter,
  onFilterChange,
  source,
}: FilterBarProps) {
  const filters = source === "cosmos" ? COSMOS_FILTERS : DAO_FILTERS;

  return (
    <div className="filter-bar">
      {filters.map((f) => (
        <button
          key={f.key}
          className={`filter-btn ${activeFilter === f.key ? "active" : ""}`}
          onClick={() => onFilterChange(f.key)}
        >
          {f.label}
        </button>
      ))}
    </div>
  );
}

3.6 SearchBar 组件

// components/ProposalList/SearchBar.tsx
import React, { useState, useCallback } from "react";

interface SearchBarProps {
  onSearch: (query: string) => void;
  placeholder?: string;
}

export function SearchBar({
  onSearch,
  placeholder = "搜索提案标题...",
}: SearchBarProps) {
  const [value, setValue] = useState("");

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setValue(e.target.value);
    },
    []
  );

  const handleSubmit = useCallback(
    (e: React.FormEvent) => {
      e.preventDefault();
      onSearch(value);
    },
    [value, onSearch]
  );

  const handleClear = useCallback(() => {
    setValue("");
    onSearch("");
  }, [onSearch]);

  return (
    <form className="search-bar" onSubmit={handleSubmit}>
      <input
        type="text"
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
        className="search-input"
      />
      {value && (
        <button
          type="button"
          className="search-clear"
          onClick={handleClear}
        >
          ✕
        </button>
      )}
      <button type="submit" className="search-submit">
        搜索
      </button>
    </form>
  );
}

3.7 完整 ProposalList 组件

// components/ProposalList/ProposalList.tsx
import React, { useState, useMemo, useCallback } from "react";
import { useCosmosProposals, useDaoProposals } from "../../hooks/useProposals";
import { ProposalCard } from "./ProposalCard";
import { FilterBar } from "./FilterBar";
import { SearchBar } from "./SearchBar";
import "./ProposalList.css";

interface ProposalListProps {
  status: "all" | "voting" | "passed" | "rejected" | "executed";
  onSelect: (id: number) => void;
  source: "cosmos" | "dao";
  client: any;
  daoAddress: string | null;
}

export function ProposalList({
  status,
  onSelect,
  source,
  client,
  daoAddress,
}: ProposalListProps) {
  const [filter, setFilter] = useState<string>(status);
  const [searchQuery, setSearchQuery] = useState("");

  const {
    proposals: cosmosProposals,
    loading: cosmosLoading,
    error: cosmosError,
    loadMore,
    hasMore,
  } = useCosmosProposals(filter);

  const {
    proposals: daoProposals,
    loading: daoLoading,
    error: daoError,
  } = useDaoProposals(filter, client, daoAddress);

  const proposals = source === "cosmos" ? cosmosProposals : daoProposals;
  const loading = source === "cosmos" ? cosmosLoading : daoLoading;
  const error = source === "cosmos" ? cosmosError : daoError;

  const filteredProposals = useMemo(() => {
    if (!searchQuery) return proposals;
    const q = searchQuery.toLowerCase();
    return proposals.filter((p: any) => {
      const title = source === "cosmos" ? p.content?.title : p.title;
      return title?.toLowerCase().includes(q);
    });
  }, [proposals, searchQuery, source]);

  const handleSearch = useCallback((query: string) => {
    setSearchQuery(query);
  }, []);

  const handleFilterChange = useCallback((newFilter: string) => {
    setFilter(newFilter);
  }, []);

  if (loading && proposals.length === 0) {
    return <div className="loading-state">加载提案中...</div>;
  }

  if (error) {
    return <div className="error-state">加载失败: {error}</div>;
  }

  return (
    <div className="proposal-list">
      <div className="proposal-list-toolbar">
        <FilterBar
          activeFilter={filter}
          onFilterChange={handleFilterChange}
          source={source}
        />
        <SearchBar onSearch={handleSearch} />
      </div>

      {filteredProposals.length === 0 ? (
        <div className="empty-state">
          <p>暂无提案</p>
          {searchQuery && <p>尝试修改搜索关键词</p>}
        </div>
      ) : (
        <>
          <div className="proposal-cards">
            {filteredProposals.map((proposal: any) => {
              const id =
                source === "cosmos"
                  ? Number(proposal.proposal_id)
                  : proposal.id;
              return (
                <ProposalCard
                  key={id}
                  proposal={proposal}
                  onClick={() => onSelect(id)}
                  source={source}
                />
              );
            })}
          </div>

          {source === "cosmos" && hasMore && (
            <div className="load-more">
              <button
                onClick={loadMore}
                disabled={loading}
                className="load-more-btn"
              >
                {loading ? "加载中..." : "加载更多"}
              </button>
            </div>
          )}
        </>
      )}
    </div>
  );
}

3.8 ProposalList CSS

.proposal-list {
  max-width: 960px;
  margin: 0 auto;
  padding: 20px;
}

.proposal-list-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  flex-wrap: wrap;
  gap: 12px;
}

.filter-bar {
  display: flex;
  gap: 8px;
}

.filter-btn {
  padding: 8px 16px;
  border: 1px solid #ddd;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.filter-btn:hover {
  background: #f0f0f0;
}

.filter-btn.active {
  background: #1976D2;
  color: #fff;
  border-color: #1976D2;
}

.search-bar {
  display: flex;
  gap: 8px;
  align-items: center;
}

.search-input {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
  width: 220px;
  outline: none;
}

.search-input:focus {
  border-color: #1976D2;
}

.search-clear {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  color: #999;
}

.search-submit {
  padding: 8px 16px;
  background: #1976D2;
  color: #fff;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
}

.search-submit:hover {
  background: #1565C0;
}

.proposal-cards {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.proposal-card {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px 20px;
  cursor: pointer;
  transition: all 0.2s;
}

.proposal-card:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  border-color: #1976D2;
}

.proposal-card-header {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
}

.proposal-id {
  font-weight: 600;
  color: #666;
  font-size: 13px;
}

.status-badge {
  padding: 2px 10px;
  border-radius: 12px;
  color: #fff;
  font-size: 12px;
  font-weight: 500;
}

.proposal-type {
  font-size: 12px;
  color: #999;
  background: #f5f5f5;
  padding: 2px 8px;
  border-radius: 4px;
}

.proposal-card-title {
  font-size: 16px;
  font-weight: 600;
  margin: 0 0 8px;
  color: #222;
  line-height: 1.4;
}

.proposal-card-desc {
  font-size: 14px;
  color: #666;
  margin: 0 0 12px;
  line-height: 1.5;
}

.proposal-card-meta {
  display: flex;
  gap: 20px;
  font-size: 13px;
  color: #888;
}

.meta-item {
  display: flex;
  gap: 4px;
}

.meta-label {
  color: #aaa;
}

.meta-value {
  color: #666;
}

.mini-tally {
  margin-top: 12px;
}

.mini-tally-bar {
  display: flex;
  height: 6px;
  border-radius: 3px;
  overflow: hidden;
  background: #eee;
}

.mini-bar-segment.yes {
  background: #4CAF50;
}

.mini-bar-segment.no {
  background: #F44336;
}

.mini-bar-segment.abstain {
  background: #9E9E9E;
}

.mini-bar-segment.veto {
  background: #FF9800;
}

.mini-tally-empty {
  font-size: 12px;
  color: #999;
  text-align: center;
}

.loading-state,
.error-state,
.empty-state {
  text-align: center;
  padding: 60px 20px;
  color: #888;
}

.load-more {
  text-align: center;
  padding: 20px;
}

.load-more-btn {
  padding: 10px 24px;
  background: #f5f5f5;
  border: 1px solid #ddd;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
}

.load-more-btn:hover {
  background: #e0e0e0;
}

.load-more-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}


4. 提案詳情頁

4.1 数据类型

export interface ProposalDetailProps {
  proposalId: number;
  source: "cosmos" | "dao";
  client: any;
  signingClient: any;
  daoAddress: string | null;
  address: string | null;
  isConnected: boolean;
}

4.2 Cosmos 提案查询工具

// utils/cosmosQueries.ts
import { CHAIN_CONFIG } from "../config/chain";

export async function fetchCosmosProposalDetail(proposalId: number) {
  const url = `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/proposals/${proposalId}`;
  const res = await fetch(url);
  const data = await res.json();
  return data.proposal;
}

export async function fetchCosmosProposalTally(proposalId: number) {
  const url = `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/proposals/${proposalId}/tally`;
  const res = await fetch(url);
  const data = await res.json();
  return data.tally;
}

export async function fetchCosmosProposalVotes(
  proposalId: number,
  limit: number = 100
) {
  const url = `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/proposals/${proposalId}/votes?pagination.limit=${limit}`;
  const res = await fetch(url);
  const data = await res.json();
  return data.votes || [];
}

export async function fetchCosmosProposalDeposits(proposalId: number) {
  const url = `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/proposals/${proposalId}/deposits`;
  const res = await fetch(url);
  const data = await res.json();
  return data.deposits || [];
}

4.3 Timeline 组件

// components/ProposalDetail/Timeline.tsx
import React from "react";

interface TimelineEvent {
  label: string;
  time: string | null;
  status: "completed" | "active" | "pending";
}

interface TimelineProps {
  submitTime: string | null;
  depositEndTime: string | null;
  votingStartTime: string | null;
  votingEndTime: string | null;
  proposalStatus: string;
}

function formatTime(time: string | null): string {
  if (!time) return "—";
  const d = new Date(time);
  return d.toLocaleDateString("zh-CN", {
    month: "short",
    day: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
}

export function Timeline({
  submitTime,
  depositEndTime,
  votingStartTime,
  votingEndTime,
  proposalStatus,
}: TimelineProps) {
  const isVoting = proposalStatus === "PROPOSAL_STATUS_VOTING_PERIOD";
  const isPassed = proposalStatus === "PROPOSAL_STATUS_PASSED";
  const isRejected =
    proposalStatus === "PROPOSAL_STATUS_REJECTED" ||
    proposalStatus === "PROPOSAL_STATUS_FAILED";
  const isDeposit =
    proposalStatus === "PROPOSAL_STATUS_DEPOSIT_PERIOD";

  const events: TimelineEvent[] = [
    {
      label: "已提交",
      time: submitTime,
      status: submitTime ? "completed" : "pending",
    },
    {
      label: "保证金期结束",
      time: depositEndTime,
      status:
        depositEndTime &&
        new Date(depositEndTime).getTime() < Date.now()
          ? "completed"
          : isDeposit
            ? "active"
            : "pending",
    },
    {
      label: "投票开始",
      time: votingStartTime,
      status:
        votingStartTime &&
        new Date(votingStartTime).getTime() < Date.now()
          ? "completed"
          : "pending",
    },
    {
      label: "投票结束",
      time: votingEndTime,
      status: isVoting
        ? "active"
        : votingEndTime &&
            new Date(votingEndTime).getTime() < Date.now()
          ? "completed"
          : "pending",
    },
    {
      label: isRejected ? "已拒绝" : "已通过",
      time: null,
      status: isPassed ? "completed" : isRejected ? "completed" : "pending",
    },
  ];

  return (
    <div className="timeline">
      <h3 className="timeline-title">提案时间线</h3>
      <div className="timeline-track">
        {events.map((event, i) => (
          <div
            key={i}
            className={`timeline-item timeline-${event.status}`}
          >
            <div className="timeline-dot" />
            <div className="timeline-content">
              <span className="timeline-label">{event.label}</span>
              <span className="timeline-time">{formatTime(event.time)}</span>
            </div>
            {i < events.length - 1 && <div className="timeline-line" />}
          </div>
        ))}
      </div>
    </div>
  );
}

4.4 VoteTallyChart 组件(Recharts 饼图)

// components/ProposalDetail/VoteTallyChart.tsx
import React from "react";
import {
  PieChart,
  Pie,
  Cell,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from "recharts";

interface VoteTally {
  yes: string;
  no: string;
  abstain: string;
  no_with_veto: string;
}

interface VoteTallyChartProps {
  tally: VoteTally | null;
}

const COLORS = {
  yes: "#4CAF50",
  no: "#F44336",
  abstain: "#9E9E9E",
  veto: "#FF9800",
};

function formatBalance(amount: string): string {
  return (
    (Number(amount) / 1_000_000).toLocaleString("zh-CN", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    }) + " MSG"
  );
}

export function VoteTallyChart({ tally }: VoteTallyChartProps) {
  if (!tally) {
    return <div className="chart-empty">暂无投票数据</div>;
  }

  const total = ["yes", "no", "abstain", "no_with_veto"].reduce(
    (sum, key) => sum + Number(tally[key as keyof VoteTally]),
    0
  );

  if (total === 0) {
    return <div className="chart-empty">暂无投票数据</div>;
  }

  const data = [
    { name: "赞成", value: Number(tally.yes), key: "yes" },
    { name: "反对", value: Number(tally.no), key: "no" },
    { name: "弃权", value: Number(tally.abstain), key: "abstain" },
    { name: "否决", value: Number(tally.no_with_veto), key: "veto" },
  ].filter((d) => d.value > 0);

  const yesPct = (Number(tally.yes) / total) * 100;
  const noPct = (Number(tally.no) / total) * 100;
  const abstainPct = (Number(tally.abstain) / total) * 100;
  const vetoPct = (Number(tally.no_with_veto) / total) * 100;

  return (
    <div className="vote-tally-chart">
      <h3>投票分布</h3>

      <div className="chart-container">
        <ResponsiveContainer width="100%" height={280}>
          <PieChart>
            <Pie
              cx="50%"
              cy="50%"
              innerRadius={60}
              outerRadius={100}
              dataKey="value"
              data={data}
              label={({ name, percent }) =>
                `${name} ${(percent * 100).toFixed(1)}%`
              }
            >
              {data.map((entry) => (
                <Cell
                  key={entry.key}
                  fill={COLORS[entry.key as keyof typeof COLORS]}
                />
              ))}
            </Pie>
            <Tooltip
              formatter={(value: number) => formatBalance(String(value))}
            />
            <Legend />
          </PieChart>
        </ResponsiveContainer>
      </div>

      <div className="tally-breakdown">
        <div className="tally-row">
          <span className="tally-label">
            <span className="dot yes" /> 赞成
          </span>
          <div className="tally-bar-bg">
            <div
              className="tally-bar-fill yes"
              style={{ width: `${yesPct}%` }}
            />
          </div>
          <span className="tally-value">{yesPct.toFixed(1)}%</span>
          <span className="tally-amount">{formatBalance(tally.yes)}</span>
        </div>

        <div className="tally-row">
          <span className="tally-label">
            <span className="dot no" /> 反对
          </span>
          <div className="tally-bar-bg">
            <div
              className="tally-bar-fill no"
              style={{ width: `${noPct}%` }}
            />
          </div>
          <span className="tally-value">{noPct.toFixed(1)}%</span>
          <span className="tally-amount">{formatBalance(tally.no)}</span>
        </div>

        <div className="tally-row">
          <span className="tally-label">
            <span className="dot abstain" /> 弃权
          </span>
          <div className="tally-bar-bg">
            <div
              className="tally-bar-fill abstain"
              style={{ width: `${abstainPct}%` }}
            />
          </div>
          <span className="tally-value">{abstainPct.toFixed(1)}%</span>
          <span className="tally-amount">{formatBalance(tally.abstain)}</span>
        </div>

        <div className="tally-row">
          <span className="tally-label">
            <span className="dot veto" /> 否决
          </span>
          <div className="tally-bar-bg">
            <div
              className="tally-bar-fill veto"
              style={{ width: `${vetoPct}%` }}
            />
          </div>
          <span className="tally-value">{vetoPct.toFixed(1)}%</span>
          <span className="tally-amount">
            {formatBalance(tally.no_with_veto)}
          </span>
        </div>
      </div>

      <div className="tally-summary">
        <span>总投票数: {formatBalance(String(total))}</span>
        <span>参与率: {((total / 1_000_000_000_000) * 100).toFixed(1)}%</span>
      </div>
    </div>
  );
}

4.5 VoterList 组件

// components/ProposalDetail/VoterList.tsx
import React, { useState, useEffect } from "react";
import { fetchCosmosProposalVotes } from "../../utils/cosmosQueries";

interface Voter {
  voter: string;
  option: string;
  weight: string;
}

interface VoterListProps {
  proposalId: number;
  source: "cosmos" | "dao";
  client: any;
  daoAddress: string | null;
}

const VOTE_LABELS: Record<string, string> = {
  VOTE_OPTION_YES: "赞成",
  VOTE_OPTION_NO: "反对",
  VOTE_OPTION_ABSTAIN: "弃权",
  VOTE_OPTION_NO_WITH_VETO: "否决",
  yes: "赞成",
  no: "反对",
  abstain: "弃权",
  veto: "否决",
};

const VOTE_COLORS: Record<string, string> = {
  VOTE_OPTION_YES: "#4CAF50",
  VOTE_OPTION_NO: "#F44336",
  VOTE_OPTION_ABSTAIN: "#9E9E9E",
  VOTE_OPTION_NO_WITH_VETO: "#FF9800",
  yes: "#4CAF50",
  no: "#F44336",
  abstain: "#9E9E9E",
  veto: "#FF9800",
};

function truncateAddress(addr: string): string {
  if (!addr) return "";
  return `${addr.slice(0, 10)}...${addr.slice(-8)}`;
}

export function VoterList({
  proposalId,
  source,
  client,
  daoAddress,
}: VoterListProps) {
  const [voters, setVoters] = useState<Voter[]>([]);
  const [loading, setLoading] = useState(true);
  const [sortBy, setSortBy] = useState<"weight" | "voter">("weight");
  const [filterVote, setFilterVote] = useState<string>("");

  useEffect(() => {
    if (source === "cosmos") {
      loadCosmosVoters();
    } else if (client && daoAddress) {
      loadDaoVoters();
    }
  }, [proposalId, source, client, daoAddress]);

  async function loadCosmosVoters() {
    setLoading(true);
    try {
      const votes = await fetchCosmosProposalVotes(proposalId);
      setVoters(
        votes.map((v: any) => ({
          voter: v.voter,
          option: v.option,
          weight: v.weight || "0",
        }))
      );
    } catch (err) {
      console.error("加载投票人失败:", err);
    } finally {
      setLoading(false);
    }
  }

  async function loadDaoVoters() {
    if (!client || !daoAddress) return;
    setLoading(true);
    try {
      setVoters([]);
    } catch (err) {
      console.error("加载 DAO 投票人失败:", err);
    } finally {
      setLoading(false);
    }
  }

  const filteredVoters = voters
    .filter((v) => !filterVote || v.option === filterVote)
    .sort((a, b) => {
      if (sortBy === "weight") return Number(b.weight) - Number(a.weight);
      return a.voter.localeCompare(b.voter);
    });

  if (loading) {
    return <div className="voter-list-loading">加载投票列表中...</div>;
  }

  return (
    <div className="voter-list">
      <div className="voter-list-header">
        <h3>投票列表 ({voters.length})</h3>
        <div className="voter-list-controls">
          <select
            value={filterVote}
            onChange={(e) => setFilterVote(e.target.value)}
            className="voter-filter-select"
          >
            <option value="">全部</option>
            <option value="VOTE_OPTION_YES">赞成</option>
            <option value="VOTE_OPTION_NO">反对</option>
            <option value="VOTE_OPTION_ABSTAIN">弃权</option>
            <option value="VOTE_OPTION_NO_WITH_VETO">否决</option>
          </select>
          <select
            value={sortBy}
            onChange={(e) =>
              setSortBy(e.target.value as "weight" | "voter")
            }
            className="voter-sort-select"
          >
            <option value="weight">按权重</option>
            <option value="voter">按地址</option>
          </select>
        </div>
      </div>

      {filteredVoters.length === 0 ? (
        <p className="voter-list-empty">暂无投票记录</p>
      ) : (
        <div className="voter-table-wrapper">
          <table className="voter-table">
            <thead>
              <tr>
                <th>#</th>
                <th>投票人</th>
                <th>选项</th>
                <th>权重 (MSG)</th>
              </tr>
            </thead>
            <tbody>
              {filteredVoters.map((voter, index) => (
                <tr key={voter.voter}>
                  <td>{index + 1}</td>
                  <td className="voter-address" title={voter.voter}>
                    {truncateAddress(voter.voter)}
                  </td>
                  <td>
                    <span
                      className="vote-option-badge"
                      style={{
                        backgroundColor:
                          VOTE_COLORS[voter.option] || "#999",
                      }}
                    >
                      {VOTE_LABELS[voter.option] || voter.option}
                    </span>
                  </td>
                  <td className="voter-weight">
                    {(Number(voter.weight) / 1_000_000).toLocaleString(
                      "zh-CN",
                      { minimumFractionDigits: 2 }
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}

4.6 完整的 ProposalDetail 组件

// components/ProposalDetail/ProposalDetail.tsx
import React, { useState, useEffect } from "react";
import {
  fetchCosmosProposalDetail,
  fetchCosmosProposalTally,
} from "../../utils/cosmosQueries";
import { VoteTallyChart } from "./VoteTallyChart";
import { Timeline } from "./Timeline";
import { VoterList } from "./VoterList";
import { VotePanel } from "../VotePanel/VotePanel";
import "./ProposalDetail.css";

interface ProposalDetailProps {
  proposalId: number;
  source: "cosmos" | "dao";
  client: any;
  signingClient: any;
  daoAddress: string | null;
  address: string | null;
  isConnected: boolean;
  onBack: () => void;
}

function formatAmount(amount: string, denom: string = "umsg"): string {
  const val = Number(amount) / 1_000_000;
  return (
    val.toLocaleString("zh-CN", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 6,
    }) +
    ` ${denom.replace("u", "").toUpperCase()}`
  );
}

function truncateAddress(addr: string): string {
  if (!addr) return "";
  return `${addr.slice(0, 12)}...${addr.slice(-8)}`;
}

export function ProposalDetail({
  proposalId,
  source,
  client,
  signingClient,
  daoAddress,
  address,
  isConnected,
  onBack,
}: ProposalDetailProps) {
  const [proposal, setProposal] = useState<any>(null);
  const [tally, setTally] = useState<any>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [activeTab, setActiveTab] = useState<
    "overview" | "votes" | "tally"
  >("overview");

  useEffect(() => {
    if (source === "cosmos") {
      loadCosmosDetail();
    } else if (client && daoAddress) {
      loadDaoDetail();
    }
  }, [proposalId, source, client, daoAddress]);

  async function loadCosmosDetail() {
    setLoading(true);
    try {
      const [proposalData, tallyData] = await Promise.all([
        fetchCosmosProposalDetail(proposalId),
        fetchCosmosProposalTally(proposalId),
      ]);
      setProposal(proposalData);
      setTally(tallyData);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  async function loadDaoDetail() {
    if (!client || !daoAddress) return;
    setLoading(true);
    try {
      const proposalData: any = await client.queryContractSmart(
        daoAddress,
        { get_proposal: { proposal_id: proposalId } }
      );
      let tallyData = null;
      try {
        tallyData = await client.queryContractSmart(daoAddress, {
          get_proposal_tally: { proposal_id: proposalId },
        });
      } catch {
        // tally may not exist for non-voting proposals
      }
      setProposal(proposalData);
      setTally(tallyData);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  async function handleExecute() {
    if (!signingClient || !daoAddress || !address) return;
    try {
      const tx = await signingClient.execute(
        address,
        daoAddress,
        { execute_proposal: { proposal_id: proposalId } },
        "auto"
      );
      alert(`提案已执行!交易哈希: ${tx.transactionHash}`);
      loadDaoDetail();
    } catch (err: any) {
      alert(`执行失败: ${err.message}`);
    }
  }

  if (loading) {
    return (
      <div className="detail-loading">
        <div className="loading-spinner" />
        <p>加载提案详情...</p>
      </div>
    );
  }

  if (error) {
    return (
      <div className="detail-error">
        <p>加载失败: {error}</p>
        <button onClick={onBack}>返回列表</button>
      </div>
    );
  }

  if (!proposal) return null;

  const isCosmos = source === "cosmos";
  const title = isCosmos ? proposal.content?.title : proposal.title;
  const description = isCosmos
    ? proposal.content?.description
    : proposal.description;
  const status = proposal.status;
  const isVoting = isCosmos
    ? status === "PROPOSAL_STATUS_VOTING_PERIOD"
    : status === "active";
  const isPassed = isCosmos
    ? status === "PROPOSAL_STATUS_PASSED"
    : status === "passed";
  const isExecuted = status === "executed";
  const showExecute = !isCosmos && isPassed && !isExecuted;

  const borderColor = isCosmos
    ? status === "PROPOSAL_STATUS_VOTING_PERIOD"
      ? "#2196F3"
      : status === "PROPOSAL_STATUS_PASSED"
        ? "#4CAF50"
        : "#F44336"
    : status === "active"
      ? "#2196F3"
      : status === "passed"
        ? "#4CAF50"
        : status === "executed"
          ? "#9E9E9E"
          : "#F44336";

  const statusLabel = getStatusLabel(status);

  return (
    <div className="proposal-detail">
      <div className="detail-header">
        <button className="back-btn" onClick={onBack}>
          &larr; 返回
        </button>
        <div className="detail-title-row">
          <h1 className="detail-title">
            <span className="detail-id">#{proposalId}</span>
            {title}
          </h1>
          <span
            className="detail-status"
            style={{ backgroundColor: borderColor }}
          >
            {statusLabel}
          </span>
        </div>
      </div>

      <div className="detail-tabs">
        <button
          className={`tab-btn ${activeTab === "overview" ? "active" : ""}`}
          onClick={() => setActiveTab("overview")}
        >
          概览
        </button>
        <button
          className={`tab-btn ${activeTab === "tally" ? "active" : ""}`}
          onClick={() => setActiveTab("tally")}
        >
          投票统计
        </button>
        <button
          className={`tab-btn ${activeTab === "votes" ? "active" : ""}`}
          onClick={() => setActiveTab("votes")}
        >
          投票列表
        </button>
      </div>

      <div className="detail-content">
        {activeTab === "overview" && (
          <div className="detail-overview">
            <div className="detail-section">
              <h3>描述</h3>
              <p className="detail-description">{description}</p>
            </div>

            {isCosmos && proposal.content?.changes && (
              <div className="detail-section">
                <h3>参数变更</h3>
                <table className="param-change-table">
                  <thead>
                    <tr>
                      <th>模块</th>
                      <th>参数</th>
                      <th>新值</th>
                    </tr>
                  </thead>
                  <tbody>
                    {proposal.content.changes.map(
                      (change: any, i: number) => (
                        <tr key={i}>
                          <td>{change.subspace}</td>
                          <td>{change.key}</td>
                          <td>
                            <code>{change.value}</code>
                          </td>
                        </tr>
                      )
                    )}
                  </tbody>
                </table>
              </div>
            )}

            {isCosmos && proposal.content?.plan && (
              <div className="detail-section">
                <h3>升级计划</h3>
                <div className="upgrade-plan">
                  <div className="plan-row">
                    <span className="plan-label">名称</span>
                    <span className="plan-value">
                      {proposal.content.plan.name}
                    </span>
                  </div>
                  <div className="plan-row">
                    <span className="plan-label">高度</span>
                    <span className="plan-value">
                      {Number(proposal.content.plan.height).toLocaleString()}
                    </span>
                  </div>
                  {proposal.content.plan.info && (
                    <div className="plan-row">
                      <span className="plan-label">信息</span>
                      <span className="plan-value plan-info">
                        <code>{proposal.content.plan.info}</code>
                      </span>
                    </div>
                  )}
                </div>
              </div>
            )}

            {isCosmos && proposal.content?.recipient && (
              <div className="detail-section">
                <h3>社区支出</h3>
                <div className="spend-detail">
                  <div className="spend-row">
                    <span className="spend-label">收款地址</span>
                    <span className="spend-value">
                      {truncateAddress(proposal.content.recipient)}
                    </span>
                  </div>
                  <div className="spend-row">
                    <span className="spend-label">金额</span>
                    <span className="spend-value">
                      {proposal.content.amount
                        ?.map((coin: any) =>
                          formatAmount(coin.amount, coin.denom)
                        )
                        .join(", ")}
                    </span>
                  </div>
                </div>
              </div>
            )}

            {!isCosmos && (
              <div className="detail-section">
                <h3>提案信息</h3>
                <div className="dao-proposal-info">
                  <div className="info-row">
                    <span className="info-label">提案人</span>
                    <span className="info-value">
                      {truncateAddress(proposal.proposer)}
                    </span>
                  </div>
                  <div className="info-row">
                    <span className="info-label">保证金</span>
                    <span className="info-value">
                      {formatAmount(proposal.deposit_amount)}
                    </span>
                  </div>
                  {proposal.target_contract && (
                    <div className="info-row">
                      <span className="info-label">目标合约</span>
                      <span className="info-value">
                        {truncateAddress(proposal.target_contract)}
                      </span>
                    </div>
                  )}
                  {proposal.execute_msg && (
                    <div className="info-row">
                      <span className="info-label">执行消息</span>
                      <span className="info-value">
                        <code>{proposal.execute_msg}</code>
                      </span>
                    </div>
                  )}
                </div>
              </div>
            )}

            <div className="detail-section">
              <Timeline
                submitTime={
                  isCosmos ? proposal.submit_time : null
                }
                depositEndTime={
                  isCosmos ? proposal.deposit_end_time : null
                }
                votingStartTime={
                  isCosmos ? proposal.voting_start_time : null
                }
                votingEndTime={
                  isCosmos ? proposal.voting_end_time : null
                }
                proposalStatus={status}
              />
            </div>

            {isVoting && (
              <div className="detail-section">
                <VotePanel
                  proposalId={proposalId}
                  source={source}
                  signingClient={signingClient}
                  daoAddress={daoAddress}
                  address={address}
                  isConnected={isConnected}
                  onVoteComplete={(txHash) => {
                    alert(`投票成功!交易哈希: ${txHash}`);
                    loadCosmosDetail();
                  }}
                />
              </div>
            )}

            {showExecute && (
              <div className="detail-section">
                <div className="execute-section">
                  <h3>执行提案</h3>
                  <p>该提案已通过投票,可以执行。</p>
                  <button
                    className="execute-btn"
                    onClick={handleExecute}
                  >
                    执行提案 #{proposalId}
                  </button>
                </div>
              </div>
            )}
          </div>
        )}

        {activeTab === "tally" && (
          <div className="detail-tally">
            <VoteTallyChart tally={tally} />
          </div>
        )}

        {activeTab === "votes" && (
          <div className="detail-votes">
            <VoterList
              proposalId={proposalId}
              source={source}
              client={client}
              daoAddress={daoAddress}
            />
          </div>
        )}
      </div>
    </div>
  );
}

function getStatusLabel(status: string): string {
  const labels: Record<string, string> = {
    PROPOSAL_STATUS_DEPOSIT_PERIOD: "保证金期",
    PROPOSAL_STATUS_VOTING_PERIOD: "投票中",
    PROPOSAL_STATUS_PASSED: "已通过",
    PROPOSAL_STATUS_REJECTED: "已拒绝",
    PROPOSAL_STATUS_FAILED: "失败",
    active: "投票中",
    passed: "已通过",
    rejected: "已拒绝",
    executed: "已执行",
    canceled: "已取消",
  };
  return labels[status] || status;
}

5. 提案創建

5.1 类型定义

export interface ProposalFormProps {
  proposalType: "text" | "parameter" | "spend" | "upgrade";
  onSubmit: (proposal: ProposalInput) => Promise<void>;
}

export interface ProposalInput {
  title: string;
  description: string;
  proposalType: string;
  changes?: ParamChangeInput[];
  plan?: {
    name: string;
    height: number;
    info: string;
  };
  recipient?: string;
  amount?: string;
  denom?: string;
  initialDeposit: string;
}

export interface ParamChangeInput {
  subspace: string;
  key: string;
  value: string;
}

5.2 提案创建上下文 Hook

// hooks/useProposalSubmit.ts
import { useState } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";

interface UseProposalSubmitOptions {
  signingClient: SigningCosmWasmClient | null;
  address: string | null;
}

export function useProposalSubmit({
  signingClient,
  address,
}: UseProposalSubmitOptions) {
  const [submitting, setSubmitting] = useState(false);
  const [txHash, setTxHash] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function submitCosmosProposal(proposalInput: ProposalInput) {
    if (!signingClient || !address) {
      throw new Error("钱包未连接");
    }
    setSubmitting(true);
    setError(null);
    setTxHash(null);

    try {
      let msg: any;

      switch (proposalInput.proposalType) {
        case "text":
          msg = {
            typeUrl: "/cosmos.gov.v1beta1.MsgSubmitProposal",
            value: {
              content: {
                typeUrl: "/cosmos.gov.v1beta1.TextProposal",
                value: {
                  title: proposalInput.title,
                  description: proposalInput.description,
                },
              },
              initialDeposit: [
                {
                  denom: CHAIN_CONFIG.denom,
                  amount: proposalInput.initialDeposit,
                },
              ],
              proposer: address,
            },
          };
          break;

        case "parameter":
          msg = {
            typeUrl: "/cosmos.gov.v1beta1.MsgSubmitProposal",
            value: {
              content: {
                typeUrl: "/cosmos.gov.v1beta1.ParameterChangeProposal",
                value: {
                  title: proposalInput.title,
                  description: proposalInput.description,
                  changes: proposalInput.changes?.map((c) => ({
                    subspace: c.subspace,
                    key: c.key,
                    value: c.value,
                  })),
                },
              },
              initialDeposit: [
                {
                  denom: CHAIN_CONFIG.denom,
                  amount: proposalInput.initialDeposit,
                },
              ],
              proposer: address,
            },
          };
          break;

        case "upgrade":
          msg = {
            typeUrl: "/cosmos.gov.v1beta1.MsgSubmitProposal",
            value: {
              content: {
                typeUrl: "/cosmos.gov.v1beta1.SoftwareUpgradeProposal",
                value: {
                  title: proposalInput.title,
                  description: proposalInput.description,
                  plan: proposalInput.plan,
                },
              },
              initialDeposit: [
                {
                  denom: CHAIN_CONFIG.denom,
                  amount: proposalInput.initialDeposit,
                },
              ],
              proposer: address,
            },
          };
          break;

        case "spend":
          msg = {
            typeUrl: "/cosmos.gov.v1beta1.MsgSubmitProposal",
            value: {
              content: {
                typeUrl: "/cosmos.gov.v1beta1.CommunityPoolSpendProposal",
                value: {
                  title: proposalInput.title,
                  description: proposalInput.description,
                  recipient: proposalInput.recipient,
                  amount: [
                    {
                      denom: proposalInput.denom || CHAIN_CONFIG.denom,
                      amount: proposalInput.amount,
                    },
                  ],
                },
              },
              initialDeposit: [
                {
                  denom: CHAIN_CONFIG.denom,
                  amount: proposalInput.initialDeposit,
                },
              ],
              proposer: address,
            },
          };
          break;

        default:
          throw new Error(`不支持的提案类型: ${proposalInput.proposalType}`);
      }

      const fee = {
        amount: [{ denom: CHAIN_CONFIG.denom, amount: "5000" }],
        gas: "300000",
      };

      const result = await signingClient.signAndBroadcast(
        address,
        [msg],
        fee
      );

      setTxHash(result.transactionHash);
      return result.transactionHash;
    } catch (err: any) {
      setError(err.message);
      throw err;
    } finally {
      setSubmitting(false);
    }
  }

  async function submitDaoProposal(
    daoAddress: string,
    title: string,
    description: string,
    depositAmount: string,
    targetContract?: string,
    executeMsg?: string
  ) {
    if (!signingClient || !address) {
      throw new Error("钱包未连接");
    }
    setSubmitting(true);
    setError(null);
    setTxHash(null);

    try {
      const msg = {
        submit_proposal: {
          title,
          description,
          target_contract: targetContract || null,
          execute_msg: executeMsg || null,
          deposit_amount: depositAmount,
        },
      };

      const funds = [{ denom: CHAIN_CONFIG.denom, amount: depositAmount }];

      const result = await signingClient.execute(
        address,
        daoAddress,
        msg,
        "auto",
        undefined,
        funds
      );

      setTxHash(result.transactionHash);
      return result.transactionHash;
    } catch (err: any) {
      setError(err.message);
      throw err;
    } finally {
      setSubmitting(false);
    }
  }

  return {
    submitCosmosProposal,
    submitDaoProposal,
    submitting,
    txHash,
    error,
  };
}

5.3 TextProposalForm 组件

// components/ProposalForm/TextProposalForm.tsx
import React, { useState } from "react";

interface TextProposalFormProps {
  onSubmit: (data: { title: string; description: string }) => void;
  submitting: boolean;
}

export function TextProposalForm({
  onSubmit,
  submitting,
}: TextProposalFormProps) {
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [errors, setErrors] = useState<Record<string, string>>({});

  function validate(): boolean {
    const newErrors: Record<string, string> = {};
    if (!title.trim()) newErrors.title = "请输入提案标题";
    else if (title.length > 200) newErrors.title = "标题不能超过200字";
    if (!description.trim()) newErrors.description = "请输入提案描述";
    else if (description.length < 20)
      newErrors.description = "描述至少20字";
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (validate()) {
      onSubmit({
        title: title.trim(),
        description: description.trim(),
      });
    }
  }

  return (
    <form className="proposal-form" onSubmit={handleSubmit}>
      <div className="form-group">
        <label className="form-label">
          提案标题 <span className="required">*</span>
        </label>
        <input
          type="text"
          className={`form-input ${errors.title ? "has-error" : ""}`}
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="输入清晰简洁的提案标题"
          maxLength={200}
        />
        {errors.title && (
          <span className="form-error">{errors.title}</span>
        )}
        <span className="form-hint">{title.length}/200</span>
      </div>

      <div className="form-group">
        <label className="form-label">
          提案描述 <span className="required">*</span>
        </label>
        <textarea
          className={`form-textarea ${errors.description ? "has-error" : ""}`}
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          placeholder="详细描述提案的内容、动机和预期影响..."
          rows={8}
        />
        {errors.description && (
          <span className="form-error">{errors.description}</span>
        )}
        <span className="form-hint">
          建议包含:背景、方案、影响分析、参考资料
        </span>
      </div>

      <button
        type="submit"
        className="form-submit"
        disabled={submitting}
      >
        {submitting ? "提交中..." : "提交提案"}
      </button>
    </form>
  );
}

5.4 ParamChangeForm 组件

// components/ProposalForm/ParamChangeForm.tsx
import React, { useState } from "react";
import { ParamChangeInput } from "../../types/governance";

const KNOWN_SUBSPACES = [
  { value: "staking", label: "Staking (质押)" },
  { value: "distribution", label: "Distribution (分配)" },
  { value: "mint", label: "Mint (铸造)" },
  { value: "gov", label: "Gov (治理)" },
  { value: "slashing", label: "Slashing (惩罚)" },
];

const KNOWN_PARAMS: Record<string, { value: string; label: string }[]> = {
  staking: [
    { value: "MaxValidators", label: "最大验证者数" },
    { value: "UnbondingTime", label: "解委托期" },
    { value: "MaxEntries", label: "最大解委托条目" },
  ],
  distribution: [
    { value: "communitytax", label: "社区池税率" },
    { value: "baseproposerreward", label: "基础提议者奖励" },
    { value: "bonusproposerreward", label: "额外提议者奖励" },
  ],
  mint: [
    { value: "InflationRateChange", label: "通胀率变化" },
    { value: "InflationMax", label: "最大通胀率" },
    { value: "InflationMin", label: "最小通胀率" },
  ],
  gov: [
    { value: "min_deposit", label: "最小保证金" },
    { value: "max_deposit_period", label: "最大保证金期" },
    { value: "voting_period", label: "投票期" },
    { value: "quorum", label: "法定人数" },
    { value: "threshold", label: "通过阈值" },
    { value: "veto_threshold", label: "否决阈值" },
  ],
  slashing: [
    { value: "signed_blocks_window", label: "签名窗口" },
    { value: "min_signed_per_window", label: "最低签名率" },
  ],
};

function getParamHint(subspace: string, key: string): string {
  const hints: Record<string, Record<string, string>> = {
    gov: {
      min_deposit: 'JSON 字符串,如 "10000000umsg"',
      voting_period: '秒数,如 "604800" (7天)',
      quorum: '比例,如 "0.334"',
      threshold: '比例,如 "0.500"',
      veto_threshold: '比例,如 "0.334"',
    },
    staking: {
      UnbondingTime: '秒数,如 "1814400" (21天)',
      MaxValidators: '整数,如 "100"',
    },
  };
  return hints[subspace]?.[key] || "JSON 编码的值";
}

interface ParamChangeFormProps {
  onSubmit: (data: {
    title: string;
    description: string;
    changes: ParamChangeInput[];
  }) => void;
  submitting: boolean;
}

export function ParamChangeForm({
  onSubmit,
  submitting,
}: ParamChangeFormProps) {
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [changes, setChanges] = useState<ParamChangeInput[]>([
    { subspace: "gov", key: "", value: "" },
  ]);
  const [errors, setErrors] = useState<Record<string, string>>({});

  function addChange() {
    setChanges([
      ...changes,
      { subspace: "gov", key: "", value: "" },
    ]);
  }

  function removeChange(index: number) {
    if (changes.length > 1) {
      setChanges(changes.filter((_, i) => i !== index));
    }
  }

  function updateChange(
    index: number,
    field: keyof ParamChangeInput,
    value: string
  ) {
    const updated = changes.map((c, i) =>
      i === index ? { ...c, [field]: value } : c
    );
    setChanges(updated);
  }

  function validate(): boolean {
    const newErrors: Record<string, string> = {};
    if (!title.trim()) newErrors.title = "请输入提案标题";
    if (!description.trim()) newErrors.description = "请输入提案描述";
    changes.forEach((c, i) => {
      if (!c.key) newErrors[`change_${i}_key`] = "请选择参数";
      if (!c.value) newErrors[`change_${i}_value`] = "请输入参数值";
    });
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (validate()) {
      onSubmit({
        title: title.trim(),
        description: description.trim(),
        changes,
      });
    }
  }

  return (
    <form className="proposal-form" onSubmit={handleSubmit}>
      <div className="form-group">
        <label className="form-label">
          提案标题 <span className="required">*</span>
        </label>
        <input
          type="text"
          className={`form-input ${errors.title ? "has-error" : ""}`}
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="如:调整治理参数以优化投票效率"
          maxLength={200}
        />
        {errors.title && (
          <span className="form-error">{errors.title}</span>
        )}
      </div>

      <div className="form-group">
        <label className="form-label">
          提案描述 <span className="required">*</span>
        </label>
        <textarea
          className={`form-textarea ${errors.description ? "has-error" : ""}`}
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          placeholder="说明为什么需要修改这些参数..."
          rows={4}
        />
        {errors.description && (
          <span className="form-error">{errors.description}</span>
        )}
      </div>

      <div className="form-group">
        <div className="form-label-row">
          <label className="form-label">参数变更</label>
          <button
            type="button"
            className="btn-add"
            onClick={addChange}
          >
            + 添加变更
          </button>
        </div>

        {changes.map((change, index) => (
          <div key={index} className="param-change-row">
            <div className="param-change-fields">
              <select
                className="form-select"
                value={change.subspace}
                onChange={(e) =>
                  updateChange(index, "subspace", e.target.value)
                }
              >
                {KNOWN_SUBSPACES.map((s) => (
                  <option key={s.value} value={s.value}>
                    {s.label}
                  </option>
                ))}
              </select>

              <select
                className={`form-select ${
                  errors[`change_${index}_key`] ? "has-error" : ""
                }`}
                value={change.key}
                onChange={(e) =>
                  updateChange(index, "key", e.target.value)
                }
              >
                <option value="">选择参数...</option>
                {(KNOWN_PARAMS[change.subspace] || []).map((p) => (
                  <option key={p.value} value={p.value}>
                    {p.label} ({p.value})
                  </option>
                ))}
              </select>

              <div className="param-value-wrapper">
                <input
                  type="text"
                  className={`form-input ${
                    errors[`change_${index}_value`] ? "has-error" : ""
                  }`}
                  value={change.value}
                  onChange={(e) =>
                    updateChange(index, "value", e.target.value)
                  }
                  placeholder='JSON 值,如 "0.334"'
                />
                {change.subspace && change.key && (
                  <span className="param-hint">
                    {getParamHint(change.subspace, change.key)}
                  </span>
                )}
              </div>

              <button
                type="button"
                className="btn-remove"
                onClick={() => removeChange(index)}
                disabled={changes.length <= 1}
              >
                ✕
              </button>
            </div>
            {errors[`change_${index}_key`] && (
              <span className="form-error">
                {errors[`change_${index}_key`]}
              </span>
            )}
            {errors[`change_${index}_value`] && (
              <span className="form-error">
                {errors[`change_${index}_value`]}
              </span>
            )}
          </div>
        ))}
      </div>

      <button
        type="submit"
        className="form-submit"
        disabled={submitting}
      >
        {submitting ? "提交中..." : "提交参数修改提案"}
      </button>
    </form>
  );
}

### 5.5 SpendForm 组件

```typescript
// components/ProposalForm/SpendForm.tsx
import React, { useState } from "react";
import { CHAIN_CONFIG } from "../../config/chain";

interface SpendFormProps {
  onSubmit: (data: {
    title: string;
    description: string;
    recipient: string;
    amount: string;
    denom: string;
  }) => void;
  submitting: boolean;
}

export function SpendForm({ onSubmit, submitting }: SpendFormProps) {
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");
  const [denom, setDenom] = useState(CHAIN_CONFIG.denom);
  const [errors, setErrors] = useState<Record<string, string>>({});

  function isValidBech32(address: string): boolean {
    return (
      address.startsWith(`${CHAIN_CONFIG.prefix}1`) && address.length >= 39
    );
  }

  function validate(): boolean {
    const newErrors: Record<string, string> = {};
    if (!title.trim()) newErrors.title = "请输入提案标题";
    if (!description.trim()) newErrors.description = "请输入提案描述";
    if (!recipient.trim()) {
      newErrors.recipient = "请输入收款地址";
    } else if (!isValidBech32(recipient)) {
      newErrors.recipient = "无效的 MSG 地址";
    }
    if (!amount || Number(amount) <= 0) {
      newErrors.amount = "请输入有效金额";
    }
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (validate()) {
      onSubmit({
        title: title.trim(),
        description: description.trim(),
        recipient: recipient.trim(),
        amount,
        denom,
      });
    }
  }

  return (
    <form className="proposal-form" onSubmit={handleSubmit}>
      <div className="form-group">
        <label className="form-label">
          提案标题 <span className="required">*</span>
        </label>
        <input
          type="text"
          className={`form-input ${errors.title ? "has-error" : ""}`}
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="如:资助 MSG Chain 开发者社区"
          maxLength={200}
        />
        {errors.title && (
          <span className="form-error">{errors.title}</span>
        )}
      </div>

      <div className="form-group">
        <label className="form-label">
          提案描述 <span className="required">*</span>
        </label>
        <textarea
          className={`form-textarea ${
            errors.description ? "has-error" : ""
          }`}
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          placeholder="说明资金的用途、预期成果和受益人..."
          rows={4}
        />
        {errors.description && (
          <span className="form-error">{errors.description}</span>
        )}
      </div>

      <div className="form-group">
        <label className="form-label">
          收款地址 <span className="required">*</span>
        </label>
        <input
          type="text"
          className={`form-input monospace ${
            errors.recipient ? "has-error" : ""
          }`}
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
          placeholder={`${CHAIN_CONFIG.prefix}1...`}
        />
        {errors.recipient && (
          <span className="form-error">{errors.recipient}</span>
        )}
        <span className="form-hint">输入有效的 MSG 链地址</span>
      </div>

      <div className="form-row">
        <div className="form-group flex-1">
          <label className="form-label">
            金额 <span className="required">*</span>
          </label>
          <input
            type="number"
            className={`form-input ${errors.amount ? "has-error" : ""}`}
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
            min="0"
            step="0.000001"
            placeholder="1000"
          />
          {errors.amount && (
            <span className="form-error">{errors.amount}</span>
          )}
        </div>

        <div className="form-group flex-1">
          <label className="form-label">代币</label>
          <select
            className="form-select"
            value={denom}
            onChange={(e) => setDenom(e.target.value)}
          >
            <option value="umsg">MSG (umsg)</option>
          </select>
        </div>
      </div>

      <div className="form-group amount-display">
        <span className="amount-label">实际发送</span>
        <span className="amount-value">
          {amount
            ? `${(Number(amount) / 1_000_000).toLocaleString()} MSG`
            : "—"}
        </span>
      </div>

      <button
        type="submit"
        className="form-submit"
        disabled={submitting}
      >
        {submitting ? "提交中..." : "提交社区支出提案"}
      </button>
    </form>
  );
}

5.6 UpgradeForm 组件

// components/ProposalForm/UpgradeForm.tsx
import React, { useState } from "react";

interface UpgradeFormProps {
  onSubmit: (data: {
    title: string;
    description: string;
    plan: { name: string; height: number; info: string };
  }) => void;
  submitting: boolean;
}

export function UpgradeForm({ onSubmit, submitting }: UpgradeFormProps) {
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [name, setName] = useState("");
  const [height, setHeight] = useState("");
  const [info, setInfo] = useState("");
  const [errors, setErrors] = useState<Record<string, string>>({});

  function validate(): boolean {
    const newErrors: Record<string, string> = {};
    if (!title.trim()) newErrors.title = "请输入提案标题";
    if (!description.trim()) newErrors.description = "请输入提案描述";
    if (!name.trim()) newErrors.name = "请输入升级名称";
    if (!height || Number(height) <= 0)
      newErrors.height = "请输入有效区块高度";
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (validate()) {
      onSubmit({
        title: title.trim(),
        description: description.trim(),
        plan: {
          name: name.trim(),
          height: Number(height),
          info: info.trim(),
        },
      });
    }
  }

  return (
    <form className="proposal-form" onSubmit={handleSubmit}>
      <div className="form-group">
        <label className="form-label">
          提案标题 <span className="required">*</span>
        </label>
        <input
          type="text"
          className={`form-input ${errors.title ? "has-error" : ""}`}
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="如:升级 MSG Chain 至 v2.0.0"
        />
        {errors.title && (
          <span className="form-error">{errors.title}</span>
        )}
      </div>

      <div className="form-group">
        <label className="form-label">
          提案描述 <span className="required">*</span>
        </label>
        <textarea
          className={`form-textarea ${
            errors.description ? "has-error" : ""
          }`}
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          placeholder="列出升级内容、变更日志和兼容性说明..."
          rows={5}
        />
        {errors.description && (
          <span className="form-error">{errors.description}</span>
        )}
      </div>

      <div className="form-group">
        <label className="form-label">
          升级名称 <span className="required">*</span>
        </label>
        <input
          type="text"
          className={`form-input ${errors.name ? "has-error" : ""}`}
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="v2.0.0"
        />
        {errors.name && (
          <span className="form-error">{errors.name}</span>
        )}
        <span className="form-hint">版本号,如 v1.1.0</span>
      </div>

      <div className="form-group">
        <label className="form-label">
          升级高度 <span className="required">*</span>
        </label>
        <input
          type="number"
          className={`form-input ${errors.height ? "has-error" : ""}`}
          value={height}
          onChange={(e) => setHeight(e.target.value)}
          min="1"
          placeholder="8500000"
        />
        {errors.height && (
          <span className="form-error">{errors.height}</span>
        )}
        <span className="form-hint">
          当前高度: 查询最新区块获取
        </span>
      </div>

      <div className="form-group">
        <label className="form-label">升级信息 (可选)</label>
        <textarea
          className="form-textarea"
          value={info}
          onChange={(e) => setInfo(e.target.value)}
          placeholder='{"binaries":{"linux/amd64":"https://..."}}'
          rows={3}
        />
        <span className="form-hint">
          JSON 格式,包含二进制下载链接和校验和
        </span>
      </div>

      <button
        type="submit"
        className="form-submit"
        disabled={submitting}
      >
        {submitting ? "提交中..." : "提交软件升级提案"}
      </button>
    </form>
  );
}

5.7 DepositSelector 组件

// components/ProposalForm/DepositSelector.tsx
import React from "react";

interface DepositSelectorProps {
  value: string;
  onChange: (value: string) => void;
  minDeposit?: string;
}

export function DepositSelector({
  value,
  onChange,
  minDeposit = "10000000",
}: DepositSelectorProps) {
  const minMsg = Number(minDeposit) / 1_000_000;

  const presetAmounts = [
    { label: "最小", value: minDeposit },
    { label: "2x", value: String(Number(minDeposit) * 2) },
    { label: "5x", value: String(Number(minDeposit) * 5) },
    { label: "10x", value: String(Number(minDeposit) * 10) },
  ];

  return (
    <div className="deposit-selector">
      <label className="form-label">保证金 (umsg)</label>
      <div className="deposit-presets">
        {presetAmounts.map((preset) => (
          <button
            key={preset.label}
            type="button"
            className={`preset-btn ${
              value === preset.value ? "active" : ""
            }`}
            onClick={() => onChange(preset.value)}
          >
            {preset.label}:{" "}
            {(Number(preset.value) / 1_000_000).toLocaleString()} MSG
          </button>
        ))}
      </div>
      <input
        type="number"
        className="form-input"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        min={minDeposit}
        step="1000000"
      />
      <span className="form-hint">
        最小保证金: {minMsg.toLocaleString()} MSG (
        {Number(minDeposit).toLocaleString()} umsg)
      </span>
    </div>
  );
}

5.8 Gas 预估工具

// utils/gasEstimate.ts
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";

export const GAS_LIMITS = {
  textProposal: 200_000,
  paramChangeProposal: 250_000,
  spendProposal: 220_000,
  upgradeProposal: 240_000,
  daoSubmitProposal: 300_000,
  vote: 120_000,
  execute: 180_000,
};

export const GAS_FEES: Record<string, string> = {
  textProposal: "5000",
  paramChangeProposal: "6250",
  spendProposal: "5500",
  upgradeProposal: "6000",
  daoSubmitProposal: "7500",
  vote: "3000",
  execute: "4500",
};

export async function estimateGas(
  client: SigningCosmWasmClient,
  messages: any[],
  address: string
): Promise<number> {
  try {
    const simulation = await client.simulate(
      address,
      messages,
      undefined
    );
    return Math.ceil(simulation * CHAIN_CONFIG.gasAdjustment);
  } catch {
    return GAS_LIMITS.textProposal;
  }
}

export function formatGasFee(gas: number): string {
  const feeAtto = gas * 1_000_000_000;
  const feeMsg = feeAtto / 1e18;
  return `${feeAtto.toLocaleString()} attoMSG (${feeMsg.toFixed(10)} MSG)`;
}

5.9 完整 CreateProposal 页面

// components/ProposalForm/CreateProposal.tsx
import React, { useState } from "react";
import { useProposalSubmit } from "../../hooks/useProposalSubmit";
import { TextProposalForm } from "./TextProposalForm";
import { ParamChangeForm } from "./ParamChangeForm";
import { SpendForm } from "./SpendForm";
import { UpgradeForm } from "./UpgradeForm";
import { DepositSelector } from "./DepositSelector";
import "./ProposalForm.css";

interface CreateProposalProps {
  signingClient: any;
  address: string | null;
  isConnected: boolean;
  mode: "cosmos" | "dao";
  daoAddress: string | null;
  onCreated: (txHash: string) => void;
}

export function CreateProposal({
  signingClient,
  address,
  isConnected,
  mode,
  daoAddress,
  onCreated,
}: CreateProposalProps) {
  const [proposalType, setProposalType] = useState<string>("text");
  const [deposit, setDeposit] = useState("10000000");
  const {
    submitCosmosProposal,
    submitDaoProposal,
    submitting,
    error,
  } = useProposalSubmit({ signingClient, address });

  async function handleCosmosSubmit(
    extraData: Record<string, any>
  ) {
    try {
      const txHash = await submitCosmosProposal({
        title: extraData.title as string,
        description: extraData.description as string,
        proposalType,
        initialDeposit: deposit,
        ...extraData,
      });
      if (txHash) onCreated(txHash);
    } catch (err: any) {
      console.error("提交失败:", err);
    }
  }

  async function handleDaoSubmit(data: {
    title: string;
    description: string;
  }) {
    if (!daoAddress) return;
    try {
      const txHash = await submitDaoProposal(
        daoAddress,
        data.title,
        data.description,
        deposit
      );
      if (txHash) onCreated(txHash);
    } catch (err: any) {
      console.error("提交失败:", err);
    }
  }

  const cosmosTypes = [
    {
      value: "text",
      label: "文本提案",
      desc: "社区信号投票",
    },
    {
      value: "parameter",
      label: "参数修改",
      desc: "修改链上模块参数",
    },
    { value: "spend", label: "社区支出", desc: "从社区池拨款" },
    {
      value: "upgrade",
      label: "软件升级",
      desc: "触发链上软件升级",
    },
  ];

  return (
    <div className="create-proposal">
      <h2 className="create-title">
        {mode === "cosmos"
          ? "创建链治理提案"
          : "创建 DAO 提案"}
      </h2>

      {!isConnected && (
        <div className="connect-warning">请先连接钱包</div>
      )}

      {mode === "cosmos" && (
        <div className="proposal-type-selector">
          <label className="form-label">提案类型</label>
          <div className="type-cards">
            {cosmosTypes.map((t) => (
              <button
                key={t.value}
                type="button"
                className={`type-card ${
                  proposalType === t.value ? "active" : ""
                }`}
                onClick={() => setProposalType(t.value)}
              >
                <span className="type-card-label">{t.label}</span>
                <span className="type-card-desc">{t.desc}</span>
              </button>
            ))}
          </div>
        </div>
      )}

      {mode === "cosmos" && (
        <div className="deposit-section">
          <DepositSelector
            value={deposit}
            onChange={setDeposit}
          />
        </div>
      )}

      {mode === "cosmos" && proposalType === "text" && (
        <TextProposalForm
          onSubmit={(data) => handleCosmosSubmit(data)}
          submitting={submitting}
        />
      )}

      {mode === "cosmos" && proposalType === "parameter" && (
        <ParamChangeForm
          onSubmit={(data) => handleCosmosSubmit(data)}
          submitting={submitting}
        />
      )}

      {mode === "cosmos" && proposalType === "spend" && (
        <SpendForm
          onSubmit={(data) => handleCosmosSubmit(data)}
          submitting={submitting}
        />
      )}

      {mode === "cosmos" && proposalType === "upgrade" && (
        <UpgradeForm
          onSubmit={(data) => handleCosmosSubmit(data)}
          submitting={submitting}
        />
      )}

      {mode === "dao" && (
        <TextProposalForm
          onSubmit={handleDaoSubmit}
          submitting={submitting}
        />
      )}

      {error && (
        <div className="form-error-banner">{error}</div>
      )}
    </div>
  );
}


6. 投票系統

6.1 类型定义

export interface VotePanelProps {
  proposalId: number;
  voterWeight?: string;
  onVote: (option: VoteOption) => Promise<void>;
}

export type ClientVoteOption =
  | "VOTE_OPTION_YES"
  | "VOTE_OPTION_NO"
  | "VOTE_OPTION_ABSTAIN"
  | "VOTE_OPTION_NO_WITH_VETO";

export type DaoVoteOption = "yes" | "no" | "abstain" | "veto";

6.2 投票 Hook

// hooks/useVote.ts
import { useState } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";

interface UseVoteOptions {
  signingClient: SigningCosmWasmClient | null;
  address: string | null;
}

interface VoteResult {
  txHash: string;
  option: string;
}

export function useVote({ signingClient, address }: UseVoteOptions) {
  const [submitting, setSubmitting] = useState(false);
  const [result, setResult] = useState<VoteResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function voteCosmos(
    proposalId: number,
    option: ClientVoteOption
  ): Promise<string> {
    if (!signingClient || !address) {
      throw new Error("钱包未连接");
    }
    setSubmitting(true);
    setError(null);
    setResult(null);

    try {
      const msg = {
        typeUrl: "/cosmos.gov.v1beta1.MsgVote",
        value: {
          proposalId: BigInt(proposalId),
          voter: address,
          option: optionToNumber(option),
        },
      };

      const fee = {
        amount: [{ denom: CHAIN_CONFIG.denom, amount: "2500" }],
        gas: "120000",
      };

      const txResult = await signingClient.signAndBroadcast(
        address,
        [msg],
        fee
      );

      setResult({
        txHash: txResult.transactionHash,
        option,
      });
      return txResult.transactionHash;
    } catch (err: any) {
      setError(err.message);
      throw err;
    } finally {
      setSubmitting(false);
    }
  }

  async function voteDao(
    daoAddress: string,
    proposalId: number,
    option: DaoVoteOption
  ): Promise<string> {
    if (!signingClient || !address) {
      throw new Error("钱包未连接");
    }
    setSubmitting(true);
    setError(null);
    setResult(null);

    try {
      const msg = {
        vote: {
          proposal_id: proposalId,
          vote: option,
        },
      };

      const txResult = await signingClient.execute(
        address,
        daoAddress,
        msg,
        "auto"
      );

      setResult({
        txHash: txResult.transactionHash,
        option,
      });
      return txResult.transactionHash;
    } catch (err: any) {
      setError(err.message);
      throw err;
    } finally {
      setSubmitting(false);
    }
  }

  function optionToNumber(option: ClientVoteOption): number {
    const map: Record<ClientVoteOption, number> = {
      VOTE_OPTION_YES: 1,
      VOTE_OPTION_ABSTAIN: 2,
      VOTE_OPTION_NO: 3,
      VOTE_OPTION_NO_WITH_VETO: 4,
    };
    return map[option];
  }

  function reset() {
    setResult(null);
    setError(null);
  }

  return { voteCosmos, voteDao, submitting, result, error, reset };
}

6.3 VoteConfirmModal 组件

// components/VotePanel/VoteConfirmModal.tsx
import React from "react";
import { CHAIN_CONFIG } from "../../config/chain";

interface VoteConfirmModalProps {
  open: boolean;
  proposalId: number;
  option: string;
  optionLabel: string;
  optionColor: string;
  voterWeight: string | null;
  onConfirm: () => void;
  onCancel: () => void;
  submitting: boolean;
}

export function VoteConfirmModal({
  open,
  proposalId,
  option,
  optionLabel,
  optionColor,
  voterWeight,
  onConfirm,
  onCancel,
  submitting,
}: VoteConfirmModalProps) {
  if (!open) return null;

  const formattedWeight = voterWeight
    ? (Number(voterWeight) / 1_000_000).toLocaleString("zh-CN", {
        minimumFractionDigits: 2,
      })
    : null;

  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="modal-header">
          <h3>确认投票</h3>
          <button className="modal-close" onClick={onCancel}>
            ✕
          </button>
        </div>

        <div className="modal-body">
          <div className="confirm-info">
            <div className="confirm-row">
              <span className="confirm-label">提案</span>
              <span className="confirm-value">#{proposalId}</span>
            </div>
            <div className="confirm-row">
              <span className="confirm-label">投票选项</span>
              <span
                className="confirm-value"
                style={{ color: optionColor, fontWeight: 600 }}
              >
                {optionLabel}
              </span>
            </div>
            {formattedWeight && (
              <div className="confirm-row">
                <span className="confirm-label">投票权重</span>
                <span className="confirm-value">
                  {formattedWeight} {CHAIN_CONFIG.symbol}
                </span>
              </div>
            )}
          </div>

          <div className="confirm-warning">
            此操作将发送一笔链上交易并消耗 Gas 费用。
            投票一旦提交不能撤回。
          </div>
        </div>

        <div className="modal-footer">
          <button
            className="modal-btn cancel"
            onClick={onCancel}
            disabled={submitting}
          >
            取消
          </button>
          <button
            className="modal-btn confirm"
            onClick={onConfirm}
            disabled={submitting}
            style={{ backgroundColor: optionColor }}
          >
            {submitting ? "提交中..." : "确认投票"}
          </button>
        </div>
      </div>
    </div>
  );
}

6.4 VoteStatusBadge 组件

// components/VotePanel/VoteStatusBadge.tsx
import React from "react";

interface VoteStatusBadgeProps {
  hasVoted: boolean;
  voteOption?: string;
  txHash?: string;
}

const VOTE_LABELS: Record<string, string> = {
  VOTE_OPTION_YES: "赞成",
  VOTE_OPTION_NO: "反对",
  VOTE_OPTION_ABSTAIN: "弃权",
  VOTE_OPTION_NO_WITH_VETO: "否决",
  yes: "赞成",
  no: "反对",
  abstain: "弃权",
  veto: "否决",
};

const VOTE_COLORS: Record<string, string> = {
  VOTE_OPTION_YES: "#4CAF50",
  VOTE_OPTION_NO: "#F44336",
  VOTE_OPTION_ABSTAIN: "#9E9E9E",
  VOTE_OPTION_NO_WITH_VETO: "#FF9800",
  yes: "#4CAF50",
  no: "#F44336",
  abstain: "#9E9E9E",
  veto: "#FF9800",
};

export function VoteStatusBadge({
  hasVoted,
  voteOption,
  txHash,
}: VoteStatusBadgeProps) {
  if (!hasVoted || !voteOption) {
    return (
      <div className="vote-status-badge not-voted">
        <span className="vote-status-icon">&#x25FB;</span>
        <span>尚未投票</span>
      </div>
    );
  }

  return (
    <div
      className="vote-status-badge voted"
      style={{
        backgroundColor: VOTE_COLORS[voteOption] + "15",
        borderColor: VOTE_COLORS[voteOption],
      }}
    >
      <span
        className="vote-status-icon"
        style={{ color: VOTE_COLORS[voteOption] }}
      >
        &#x2713;
      </span>
      <span style={{ color: VOTE_COLORS[voteOption] }}>
        已投: {VOTE_LABELS[voteOption] || voteOption}
      </span>
      {txHash && (
        <a
          className="vote-tx-link"
          href={`https://explorer.msgchain.org/tx/${txHash}`}
          target="_blank"
          rel="noopener noreferrer"
        >
          查看交易
        </a>
      )}
    </div>
  );
}

6.5 完整的 VotePanel 组件

// components/VotePanel/VotePanel.tsx
import React, { useState, useCallback } from "react";
import { useVote } from "../../hooks/useVote";
import { VoteConfirmModal } from "./VoteConfirmModal";
import { VoteStatusBadge } from "./VoteStatusBadge";
import "./VotePanel.css";

interface VotePanelProps {
  proposalId: number;
  source: "cosmos" | "dao";
  signingClient: any;
  daoAddress: string | null;
  address: string | null;
  isConnected: boolean;
  voterWeight?: string;
  existingVote?: { option: string; txHash?: string } | null;
  onVoteComplete: (txHash: string) => void;
}

const VOTE_OPTIONS = [
  {
    value: "VOTE_OPTION_YES",
    daoValue: "yes",
    label: "赞成",
    color: "#4CAF50",
    icon: "\u2713",
    description: "同意该提案的执行",
  },
  {
    value: "VOTE_OPTION_NO",
    daoValue: "no",
    label: "反对",
    color: "#F44336",
    icon: "\u2715",
    description: "不同意该提案的执行",
  },
  {
    value: "VOTE_OPTION_ABSTAIN",
    daoValue: "abstain",
    label: "弃权",
    color: "#9E9E9E",
    icon: "\u2014",
    description: "中立,不参与决策",
  },
  {
    value: "VOTE_OPTION_NO_WITH_VETO",
    daoValue: "veto",
    label: "否决",
    color: "#FF9800",
    icon: "\u2691",
    description: "强烈反对,触发否决阈值",
  },
];

export function VotePanel({
  proposalId,
  source,
  signingClient,
  daoAddress,
  address,
  isConnected,
  voterWeight,
  existingVote,
  onVoteComplete,
}: VotePanelProps) {
  const [selectedOption, setSelectedOption] = useState<string>("");
  const [showConfirm, setShowConfirm] = useState(false);
  const { voteCosmos, voteDao, submitting, error, reset } = useVote({
    signingClient,
    address,
  });

  const hasVoted = !!existingVote;
  const selectedVoteData = VOTE_OPTIONS.find(
    (opt) => opt.value === selectedOption
  );

  const handleVoteClick = useCallback(
    (value: string) => {
      if (hasVoted) return;
      setSelectedOption(value);
      setShowConfirm(true);
    },
    [hasVoted]
  );

  const handleConfirm = useCallback(async () => {
    if (!selectedOption || !address) return;
    try {
      let txHash: string;
      if (source === "cosmos") {
        txHash = await voteCosmos(
          proposalId,
          selectedOption as any
        );
      } else if (daoAddress) {
        const daoOption =
          VOTE_OPTIONS.find((o) => o.value === selectedOption)
            ?.daoValue || "yes";
        txHash = await voteDao(
          daoAddress,
          proposalId,
          daoOption as any
        );
      } else {
        throw new Error("DAO 地址未配置");
      }
      setShowConfirm(false);
      onVoteComplete(txHash);
    } catch (err) {
      console.error("投票失败:", err);
    }
  }, [
    selectedOption,
    address,
    source,
    daoAddress,
    proposalId,
    voteCosmos,
    voteDao,
    onVoteComplete,
  ]);

  const handleCancel = useCallback(() => {
    setShowConfirm(false);
    setSelectedOption("");
    reset();
  }, [reset]);

  const formattedWeight = voterWeight
    ? (Number(voterWeight) / 1_000_000).toLocaleString("zh-CN", {
        minimumFractionDigits: 2,
      })
    : null;

  return (
    <div className={`vote-panel ${hasVoted ? "voted" : ""}`}>
      <div className="vote-panel-header">
        <h3>投票</h3>
        {formattedWeight && (
          <span className="voter-weight-badge">
            权重: {formattedWeight} MSG
          </span>
        )}
      </div>

      {existingVote && (
        <VoteStatusBadge
          hasVoted={true}
          voteOption={existingVote.option}
          txHash={existingVote.txHash}
        />
      )}

      {!hasVoted && (
        <>
          <div className="vote-options-grid">
            {VOTE_OPTIONS.map((opt) => (
              <button
                key={opt.value}
                className={`vote-option-btn ${
                  selectedOption === opt.value ? "selected" : ""
                }`}
                style={{
                  borderColor:
                    selectedOption === opt.value
                      ? opt.color
                      : "#e0e0e0",
                  backgroundColor:
                    selectedOption === opt.value
                      ? `${opt.color}10`
                      : "#fff",
                }}
                onClick={() => handleVoteClick(opt.value)}
              >
                <span
                  className="vote-option-icon"
                  style={{ backgroundColor: opt.color }}
                >
                  {opt.icon}
                </span>
                <span className="vote-option-label">
                  {opt.label}
                </span>
                <span className="vote-option-desc">
                  {opt.description}
                </span>
              </button>
            ))}
          </div>

          {error && (
            <div className="vote-error">投票失败: {error}</div>
          )}
        </>
      )}

      <VoteConfirmModal
        open={showConfirm}
        proposalId={proposalId}
        option={selectedOption}
        optionLabel={selectedVoteData?.label || ""}
        optionColor={selectedVoteData?.color || "#999"}
        voterWeight={voterWeight || null}
        onConfirm={handleConfirm}
        onCancel={handleCancel}
        submitting={submitting}
      />
    </div>
  );
}

6.6 VotePanel CSS

/* components/VotePanel/VotePanel.css */
.vote-panel {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px;
}

.vote-panel.voted {
  background: #fafafa;
}

.vote-panel-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
}

.vote-panel-header h3 {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
}

.voter-weight-badge {
  padding: 4px 12px;
  background: #e3f2fd;
  color: #1976D2;
  border-radius: 14px;
  font-size: 13px;
  font-weight: 500;
}

.vote-status-badge {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 16px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 16px;
}

.vote-status-badge.not-voted {
  background: #f5f5f5;
  color: #999;
  border: 1px solid #e0e0e0;
}

.vote-status-badge.voted {
  border: 1px solid;
}

.vote-status-icon {
  font-size: 16px;
}

.vote-tx-link {
  margin-left: auto;
  font-size: 13px;
  color: #1976D2;
  text-decoration: none;
  font-weight: 400;
}

.vote-tx-link:hover {
  text-decoration: underline;
}

.vote-options-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 10px;
}

.vote-option-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 16px 12px;
  border: 2px solid #e0e0e0;
  border-radius: 10px;
  background: #fff;
  cursor: pointer;
  transition: all 0.2s;
  text-align: center;
}

.vote-option-btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.vote-option-btn.selected {
  transform: translateY(-2px);
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}

.vote-option-icon {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-size: 16px;
  font-weight: bold;
}

.vote-option-label {
  font-size: 15px;
  font-weight: 600;
  color: #333;
}

.vote-option-desc {
  font-size: 12px;
  color: #888;
}

.vote-error {
  margin-top: 12px;
  padding: 10px;
  background: #ffebee;
  border: 1px solid #ffcdd2;
  color: #c62828;
  border-radius: 6px;
  font-size: 13px;
}

.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-content {
  background: #fff;
  border-radius: 12px;
  width: 420px;
  max-width: 90vw;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px 24px 0;
}

.modal-header h3 {
  margin: 0;
  font-size: 18px;
}

.modal-close {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  color: #999;
  padding: 4px;
}

.modal-body {
  padding: 20px 24px;
}

.confirm-info {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-bottom: 16px;
}

.confirm-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.confirm-label {
  color: #888;
  font-size: 14px;
}

.confirm-value {
  font-size: 14px;
  font-weight: 500;
}

.confirm-warning {
  background: #fff3e0;
  border: 1px solid #ffe0b2;
  color: #e65100;
  padding: 10px 14px;
  border-radius: 6px;
  font-size: 13px;
  line-height: 1.5;
}

.modal-footer {
  display: flex;
  gap: 10px;
  padding: 0 24px 20px;
  justify-content: flex-end;
}

.modal-btn {
  padding: 10px 24px;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: opacity 0.2s;
}

.modal-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.modal-btn.cancel {
  background: #f5f5f5;
  color: #666;
}

.modal-btn.confirm {
  color: #fff;
}


7. 治理參數顯示

7.1 数据获取 Hook

// hooks/useGovernanceParams.ts
import { useState, useEffect, useCallback } from "react";
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";

interface GovParams {
  min_deposit: { denom: string; amount: string }[];
  max_deposit_period: string;
  voting_period: string;
  quorum: string;
  threshold: string;
  veto_threshold: string;
}

interface DaoConfig {
  quorum: string;
  voting_period: number;
  threshold: string;
  owner: string;
  proposal_count: number;
  timelock_duration: number;
}

export function useCosmosGovParams() {
  const [params, setParams] = useState<GovParams | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const fetchParams = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const [deposit, voting, tally] = await Promise.all([
        fetch(
          `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/params/deposit`
        ).then((r) => r.json()),
        fetch(
          `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/params/voting`
        ).then((r) => r.json()),
        fetch(
          `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/params/tally`
        ).then((r) => r.json()),
      ]);

      setParams({
        min_deposit: deposit.deposit_params?.min_deposit || [],
        max_deposit_period:
          deposit.deposit_params?.max_deposit_period || "",
        voting_period: voting.voting_params?.voting_period || "",
        quorum: tally.tally_params?.quorum || "",
        threshold: tally.tally_params?.threshold || "",
        veto_threshold: tally.tally_params?.veto_threshold || "",
      });
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    fetchParams();
  }, [fetchParams]);

  return { params, loading, error, refresh: fetchParams };
}

export function useDaoConfig(
  client: CosmWasmClient | null,
  daoAddress: string | null
) {
  const [config, setConfig] = useState<DaoConfig | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!client || !daoAddress) return;
    let cancelled = false;

    async function fetch() {
      setLoading(true);
      try {
        const result: any = await client.queryContractSmart(
          daoAddress,
          { get_config: {} }
        );
        if (!cancelled) setConfig(result);
      } catch (err: any) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }
    fetch();
    return () => {
      cancelled = true;
    };
  }, [client, daoAddress]);

  return { config, loading, error };
}

7.2 格式化工具

// utils/format.ts
export function formatDuration(seconds: string | number): string {
  const s = typeof seconds === "string" ? parseInt(seconds) : seconds;
  const days = Math.floor(s / 86400);
  const hours = Math.floor((s % 86400) / 3600);
  const minutes = Math.floor((s % 3600) / 60);

  const parts: string[] = [];
  if (days > 0) parts.push(`${days}天`);
  if (hours > 0) parts.push(`${hours}小时`);
  if (minutes > 0) parts.push(`${minutes}分`);
  if (parts.length === 0) parts.push(`${s}秒`);
  return parts.join("");
}

export function formatPercent(value: string): string {
  const pct = Number(value) * 100;
  return pct.toFixed(1) + "%";
}

export function formatBalance(
  amount: string,
  decimals: number = 6
): string {
  const val = Number(amount) / 10 ** decimals;
  return val.toLocaleString("zh-CN", {
    minimumFractionDigits: 2,
    maximumFractionDigits: decimals,
  });
}

7.3 GovernanceParams 组件

// components/GovernanceParams/GovernanceParams.tsx
import React from "react";
import {
  useCosmosGovParams,
  useDaoConfig,
} from "../../hooks/useGovernanceParams";
import { formatDuration, formatPercent, formatBalance } from "../../utils/format";
import "./GovernanceParams.css";

interface GovernanceParamsProps {
  client: any;
  daoAddress: string | null;
}

export function GovernanceParams({
  client,
  daoAddress,
}: GovernanceParamsProps) {
  const {
    params: govParams,
    loading: govLoading,
    error: govError,
    refresh: refreshGov,
  } = useCosmosGovParams();

  const {
    config: daoConfig,
    loading: daoLoading,
    error: daoError,
  } = useDaoConfig(client, daoAddress);

  return (
    <div className="governance-params">
      <h2>治理参数</h2>

      <div className="params-grid">
        <div className="params-card">
          <div className="params-card-header">
            <h3>Cosmos SDK Gov 参数</h3>
            <button className="refresh-btn" onClick={refreshGov}>
              刷新
            </button>
          </div>

          {govLoading && <div className="params-loading">加载中...</div>}
          {govError && (
            <div className="params-error">加载失败: {govError}</div>
          )}

          {govParams && (
            <table className="params-table">
              <tbody>
                <tr>
                  <td className="param-name">最小保证金</td>
                  <td className="param-value">
                    {govParams.min_deposit
                      .map(
                        (c) =>
                          `${formatBalance(c.amount)} ${c.denom
                            .replace("u", "")
                            .toUpperCase()}`
                      )
                      .join(", ")}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">最大保证金期</td>
                  <td className="param-value">
                    {formatDuration(govParams.max_deposit_period)}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">投票期</td>
                  <td className="param-value">
                    {formatDuration(govParams.voting_period)}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">法定人数 (Quorum)</td>
                  <td className="param-value">
                    {formatPercent(govParams.quorum)}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">通过阈值 (Threshold)</td>
                  <td className="param-value">
                    {formatPercent(govParams.threshold)}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">否决阈值 (Veto)</td>
                  <td className="param-value">
                    {formatPercent(govParams.veto_threshold)}
                  </td>
                </tr>
              </tbody>
            </table>
          )}
        </div>

        {daoConfig && (
          <div className="params-card">
            <div className="params-card-header">
              <h3>DAO 合约参数</h3>
            </div>

            {daoLoading && (
              <div className="params-loading">加载中...</div>
            )}
            {daoError && (
              <div className="params-error">
                加载失败: {daoError}
              </div>
            )}

            <table className="params-table">
              <tbody>
                <tr>
                  <td className="param-name">法定人数</td>
                  <td className="param-value">
                    {formatPercent(daoConfig.quorum)}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">投票期</td>
                  <td className="param-value">
                    {formatDuration(daoConfig.voting_period)}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">通过阈值</td>
                  <td className="param-value">
                    {formatPercent(daoConfig.threshold)}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">时间锁</td>
                  <td className="param-value">
                    {formatDuration(daoConfig.timelock_duration)}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">提案总数</td>
                  <td className="param-value">
                    {daoConfig.proposal_count}
                  </td>
                </tr>
                <tr>
                  <td className="param-name">合约拥有者</td>
                  <td className="param-value mono">
                    {daoConfig.owner.slice(0, 12)}...{daoConfig.owner.slice(-6)}
                  </td>
                </tr>
              </tbody>
            </table>
          </div>
        )}
      </div>

      <div className="params-info-card">
        <h3>参数说明</h3>
        <div className="param-explanations">
          <div className="explanation-item">
            <strong>法定人数 (Quorum)</strong>
            <p>
              参与投票的票数占总票权的比例下限。
              如果投票率低于此值,提案将因未达法定人数而失败。
            </p>
          </div>
          <div className="explanation-item">
            <strong>通过阈值 (Threshold)</strong>
            <p>
              赞成票占有效票(Yes + No + Veto)的比例下限。
              Abstain 不计入此分母。
            </p>
          </div>
          <div className="explanation-item">
            <strong>否决阈值 (Veto Threshold)</strong>
            <p>
              否决票占总投票数的比例上限。
              如果否决率超过此值,即使赞成率达标,提案也会被否决。
            </p>
          </div>
          <div className="explanation-item">
            <strong>时间锁 (Timelock)</strong>
            <p>
              DAO 提案通过后需等待此时间才能执行,
              给社区留出反应和取消恶意提案的时间。
            </p>
          </div>
        </div>
      </div>

      <div className="params-scenarios">
        <h3>治理场景模拟</h3>
        <div className="scenario-cards">
          <div className="scenario-card">
            <h4>场景 A: 高参与度</h4>
            <p>总票权 1,000,000 MSG</p>
            <p>投票率: 60% ✅</p>
            <p>赞成率: 66.7% ✅</p>
            <p>否决率: 8.3% ✅</p>
            <span className="scenario-result pass">通过</span>
          </div>
          <div className="scenario-card">
            <h4>场景 B: 低参与度</h4>
            <p>总票权 1,000,000 MSG</p>
            <p>投票率: 25% ❌ (&lt;33.4%)</p>
            <p>赞成率: 80% ✅</p>
            <span className="scenario-result fail">失败</span>
          </div>
          <div className="scenario-card">
            <h4>场景 C: 高否决率</h4>
            <p>总票权 1,000,000 MSG</p>
            <p>投票率: 50% ✅</p>
            <p>赞成率: 50% ✅</p>
            <p>否决率: 40% ❌ (&gt;33.4%)</p>
            <span className="scenario-result fail">否决</span>
          </div>
        </div>
      </div>
    </div>
  );
}

7.4 GovernanceParams CSS

/* components/GovernanceParams/GovernanceParams.css */
.governance-params {
  max-width: 960px;
  margin: 0 auto;
  padding: 20px;
}

.governance-params h2 {
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 24px;
}

.params-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  margin-bottom: 24px;
}

.params-card {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px;
}

.params-card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
}

.params-card-header h3 {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
}

.refresh-btn {
  padding: 4px 12px;
  background: #f5f5f5;
  border: 1px solid #ddd;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
}

.refresh-btn:hover {
  background: #e0e0e0;
}

.params-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}

.params-table tr {
  border-bottom: 1px solid #f0f0f0;
}

.params-table tr:last-child {
  border-bottom: none;
}

.param-name {
  padding: 10px 0;
  color: #888;
  width: 140px;
}

.param-value {
  padding: 10px 0;
  text-align: right;
  font-weight: 500;
  font-family: inherit;
}

.param-value.mono {
  font-family: monospace;
  font-size: 13px;
}

.params-loading,
.params-error {
  text-align: center;
  padding: 20px;
  color: #888;
  font-size: 14px;
}

.params-error {
  color: #F44336;
}

.params-info-card {
  background: #f9f9f9;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 24px;
}

.params-info-card h3 {
  margin: 0 0 16px;
  font-size: 16px;
}

.param-explanations {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 16px;
}

.explanation-item strong {
  display: block;
  font-size: 14px;
  margin-bottom: 4px;
  color: #333;
}

.explanation-item p {
  margin: 0;
  font-size: 13px;
  color: #666;
  line-height: 1.5;
}

.params-scenarios {
  margin-bottom: 24px;
}

.params-scenarios h3 {
  font-size: 16px;
  font-weight: 600;
  margin: 0 0 16px;
}

.scenario-cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

.scenario-card {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
}

.scenario-card h4 {
  margin: 0 0 12px;
  font-size: 14px;
  font-weight: 600;
}

.scenario-card p {
  margin: 4px 0;
  font-size: 13px;
  color: #555;
}

.scenario-result {
  display: inline-block;
  margin-top: 10px;
  padding: 4px 12px;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 600;
}

.scenario-result.pass {
  background: #e8f5e9;
  color: #2e7d32;
}

.scenario-result.fail {
  background: #ffebee;
  color: #c62828;
}


8. DAO提案管理

8.1 DAO 提案查询工具

// utils/daoQueries.ts
import { CosmWasmClient, SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";

export class DaoQueries {
  private client: CosmWasmClient;
  private daoAddress: string;
  private treasuryAddress: string;
  private signingClient?: SigningCosmWasmClient;

  constructor(
    client: CosmWasmClient,
    daoAddress: string,
    treasuryAddress: string,
    signingClient?: SigningCosmWasmClient
  ) {
    this.client = client;
    this.daoAddress = daoAddress;
    this.treasuryAddress = treasuryAddress;
    this.signingClient = signingClient;
  }

  async getProposal(proposalId: number): Promise<any> {
    return this.client.queryContractSmart(this.daoAddress, {
      get_proposal: { proposal_id: proposalId },
    });
  }

  async getTally(proposalId: number): Promise<any> {
    return this.client.queryContractSmart(this.daoAddress, {
      get_proposal_tally: { proposal_id: proposalId },
    });
  }

  async getVote(proposalId: number, voter: string): Promise<any> {
    return this.client.queryContractSmart(this.daoAddress, {
      get_vote: { proposal_id: proposalId, voter },
    });
  }

  async listProposals(
    status?: string,
    startAfter?: number,
    limit: number = 30
  ): Promise<any[]> {
    const query: any = { list_proposals: { limit } };
    if (status) query.list_proposals.status = status;
    if (startAfter) query.list_proposals.start_after = startAfter;
    const result: any = await this.client.queryContractSmart(
      this.daoAddress,
      query
    );
    return result.proposals || [];
  }

  async getConfig(): Promise<any> {
    return this.client.queryContractSmart(this.daoAddress, {
      get_config: {},
    });
  }

  async getTreasuryBalance(denom: string = "umsg"): Promise<string> {
    const result: any = await this.client.queryContractSmart(
      this.treasuryAddress,
      { get_balance: { denom } }
    );
    return result.balance;
  }

  async getTreasurySigners(): Promise<{
    signers: string[];
    threshold: string;
    total_signers: number;
  }> {
    return this.client.queryContractSmart(this.treasuryAddress, {
      get_signers: {},
    });
  }

  async listTreasuryProposals(
    status?: string,
    limit: number = 30
  ): Promise<any[]> {
    const query: any = { list_proposals: { limit } };
    if (status) query.list_proposals.status = status;
    const result: any = await this.client.queryContractSmart(
      this.treasuryAddress,
      query
    );
    return result.proposals || [];
  }
}

8.2 DaoProposalList 组件

// components/DaoProposals/DaoProposalList.tsx
import React, { useState, useEffect } from "react";
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { useDao } from "../../context/DaoContext";
import "./DaoProposals.css";

interface DaoProposal {
  id: number;
  title: string;
  description: string;
  proposer: string;
  status: string;
  created_at: number;
  voting_end: number | null;
  target_contract: string | null;
  deposit_amount: string;
}

const STATUS_LABELS: Record<string, string> = {
  active: "投票中",
  passed: "已通过",
  rejected: "已拒绝",
  executed: "已执行",
  canceled: "已取消",
};

const STATUS_COLORS: Record<string, string> = {
  active: "#2196F3",
  passed: "#4CAF50",
  rejected: "#F44336",
  executed: "#9E9E9E",
  canceled: "#FF9800",
};

function truncate(addr: string): string {
  return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
}

interface DaoProposalListProps {
  onSelect: (id: number) => void;
}

export function DaoProposalList({ onSelect }: DaoProposalListProps) {
  const { client, daoAddress } = useDao();
  const [proposals, setProposals] = useState<DaoProposal[]>([]);
  const [loading, setLoading] = useState(true);
  const [filter, setFilter] = useState<string>("");
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!client || !daoAddress) return;
    fetchProposals();
  }, [client, daoAddress, filter]);

  async function fetchProposals() {
    if (!client || !daoAddress) return;
    setLoading(true);
    setError(null);
    try {
      const queryMsg: any = { list_proposals: { limit: 50 } };
      if (filter) queryMsg.list_proposals.status = filter;
      const result: any = await client.queryContractSmart(
        daoAddress,
        queryMsg
      );
      setProposals(result.proposals || []);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  function formatMsg(amount: string): string {
    return (
      (Number(amount) / 1_000_000).toLocaleString("zh-CN", {
        minimumFractionDigits: 2,
      }) + " MSG"
    );
  }

  return (
    <div className="dao-proposal-list">
      <h2>DAO 提案列表</h2>

      <div className="dao-filter-bar">
        {["", "active", "passed", "executed", "canceled"].map((f) => (
          <button
            key={f}
            className={`dao-filter-btn ${filter === f ? "active" : ""}`}
            onClick={() => setFilter(f)}
          >
            {f === ""
              ? "全部"
              : STATUS_LABELS[f] || f}
          </button>
        ))}
      </div>

      {loading && <div className="dao-loading">加载中...</div>}
      {error && <div className="dao-error">加载失败: {error}</div>}

      {!loading && proposals.length === 0 && (
        <div className="dao-empty">暂无 DAO 提案</div>
      )}

      <div className="dao-proposal-cards">
        {proposals.map((p) => (
          <div
            key={p.id}
            className="dao-proposal-card"
            onClick={() => onSelect(p.id)}
          >
            <div className="dao-card-header">
              <span className="dao-proposal-id">#{p.id}</span>
              <span
                className="dao-status-badge"
                style={{
                  backgroundColor: STATUS_COLORS[p.status] || "#999",
                }}
              >
                {STATUS_LABELS[p.status] || p.status}
              </span>
              {p.target_contract && (
                <span className="dao-contract-badge">合约执行</span>
              )}
            </div>
            <h4 className="dao-card-title">{p.title}</h4>
            <p className="dao-card-desc">
              {p.description.length > 100
                ? p.description.slice(0, 100) + "..."
                : p.description}
            </p>
            <div className="dao-card-meta">
              <span>提案人: {truncate(p.proposer)}</span>
              <span>保证金: {formatMsg(p.deposit_amount)}</span>
            </div>
            {p.target_contract && (
              <div className="dao-card-exec">
                目标合约: {truncate(p.target_contract)}
              </div>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

8.3 TreasuryDashboard 组件

// components/DaoProposals/TreasuryDashboard.tsx
import React, { useState, useEffect } from "react";
import { useDao } from "../../context/DaoContext";

interface TreasuryBalance {
  denom: string;
  balance: string;
}

interface SignerInfo {
  signers: string[];
  threshold: string;
  total_signers: number;
}

interface TreasuryProp {
  id: number;
  recipient: string;
  amount: string;
  denom: string;
  purpose: string;
  status: string;
  approvers: string[];
  created_at: number;
}

export function TreasuryDashboard() {
  const {
    client,
    signingClient,
    treasuryAddress,
    address,
    isConnected,
    connect,
  } = useDao();

  const [balance, setBalance] = useState<TreasuryBalance | null>(null);
  const [signers, setSigners] = useState<SignerInfo | null>(null);
  const [proposals, setProposals] = useState<TreasuryProp[]>([]);
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");
  const [purpose, setPurpose] = useState("");
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  useEffect(() => {
    if (!client || !treasuryAddress) return;
    fetchData();
  }, [client, treasuryAddress]);

  async function fetchData() {
    if (!client || !treasuryAddress) return;
    setLoading(true);
    try {
      const [bal, sig, props] = await Promise.all([
        client.queryContractSmart(treasuryAddress, {
          get_balance: { denom: "umsg" },
        }),
        client.queryContractSmart(treasuryAddress, {
          get_signers: {},
        }),
        client.queryContractSmart(treasuryAddress, {
          list_proposals: { limit: 20 },
        }),
      ]);
      setBalance(bal);
      setSigners(sig);
      setProposals(props.proposals || []);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  function fmt(amt: string): string {
    return (Number(amt) / 1_000_000).toLocaleString("zh-CN", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 6,
    });
  }

  async function proposeSpend() {
    if (!isConnected) await connect();
    if (!signingClient || !treasuryAddress || !address) return;
    try {
      await signingClient.execute(
        address,
        treasuryAddress,
        {
          propose_spend: {
            recipient,
            amount,
            denom: "umsg",
            purpose,
          },
        },
        "auto"
      );
      setRecipient("");
      setAmount("");
      setPurpose("");
      fetchData();
    } catch (err: any) {
      setError(err.message);
    }
  }

  async function approve(proposalId: number) {
    if (!isConnected) await connect();
    if (!signingClient || !treasuryAddress || !address) return;
    try {
      await signingClient.execute(
        address,
        treasuryAddress,
        { approve_spend: { proposal_id: proposalId } },
        "auto"
      );
      fetchData();
    } catch (err: any) {
      setError(err.message);
    }
  }

  async function execute(proposalId: number) {
    if (!isConnected) await connect();
    if (!signingClient || !treasuryAddress || !address) return;
    try {
      await signingClient.execute(
        address,
        treasuryAddress,
        { execute_spend: { proposal_id: proposalId } },
        "auto"
      );
      fetchData();
    } catch (err: any) {
      setError(err.message);
    }
  }

  if (loading) return <div className="dao-loading">加载金库数据...</div>;

  return (
    <div className="treasury-dashboard">
      <h2>DAO 金库仪表盘</h2>

      <div className="treasury-summary">
        <div className="treasury-balance-card">
          <h3>金库余额</h3>
          <div className="treasury-balance-amount">
            {balance ? fmt(balance.balance) : "—"} MSG
          </div>
        </div>

        {signers && (
          <div className="treasury-signers-card">
            <h3>金库签名人</h3>
            <p>
              共 {signers.total_signers} 人,需 {signers.threshold} 人批准
            </p>
            <div className="signer-list">
              {signers.signers.map((s: string, i: number) => (
                <span key={i} className="signer-tag" title={s}>
                  {s.slice(0, 8)}...{s.slice(-4)}
                </span>
              ))}
            </div>
          </div>
        )}
      </div>

      <div className="treasury-spend-form">
        <h3>发起支出提案</h3>
        <div className="spend-form-row">
          <input
            type="text"
            placeholder="收款地址 (msg1...)"
            value={recipient}
            onChange={(e) => setRecipient(e.target.value)}
            className="spend-input"
          />
          <input
            type="number"
            placeholder="金额 (umsg)"
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
            className="spend-input"
          />
          <input
            type="text"
            placeholder="支出用途说明"
            value={purpose}
            onChange={(e) => setPurpose(e.target.value)}
            className="spend-input"
          />
          <button className="spend-submit" onClick={proposeSpend}>
            提交支出提案
          </button>
        </div>
      </div>

      <div className="treasury-proposals">
        <h3>支出提案列表</h3>
        {proposals.length === 0 ? (
          <p className="dao-empty">暂无支出提案</p>
        ) : (
          proposals.map((p) => (
            <div key={p.id} className="treasury-proposal-card">
              <div className="treasury-prop-header">
                <span className="treasury-prop-id">#{p.id}</span>
                <span
                  className={`treasury-prop-status ${p.status}`}
                >
                  {p.status === "pending"
                    ? "待批准"
                    : p.status === "approved"
                      ? "已批准"
                      : p.status === "executed"
                        ? "已执行"
                        : p.status}
                </span>
              </div>
              <div className="treasury-prop-body">
                <div className="treasury-prop-row">
                  <span className="treasury-prop-label">收款</span>
                  <span className="treasury-prop-value">
                    {p.recipient.slice(0, 12)}...{p.recipient.slice(-6)}
                  </span>
                </div>
                <div className="treasury-prop-row">
                  <span className="treasury-prop-label">金额</span>
                  <span className="treasury-prop-value">
                    {fmt(p.amount)} {p.denom.replace("u", "").toUpperCase()}
                  </span>
                </div>
                <div className="treasury-prop-row">
                  <span className="treasury-prop-label">用途</span>
                  <span className="treasury-prop-value">{p.purpose}</span>
                </div>
                <div className="treasury-prop-row">
                  <span className="treasury-prop-label">批准进度</span>
                  <span className="treasury-prop-value">
                    {p.approvers?.length || 0}/{signers?.threshold || "?"}
                  </span>
                </div>
              </div>
              <div className="treasury-prop-actions">
                {p.status === "pending" && (
                  <button
                    className="treasury-action-btn approve"
                    onClick={() => approve(p.id)}
                  >
                    批准
                  </button>
                )}
                {p.status === "approved" && (
                  <button
                    className="treasury-action-btn execute"
                    onClick={() => execute(p.id)}
                  >
                    执行转账
                  </button>
                )}
              </div>
            </div>
          ))
        )}
      </div>

      {error && <div className="dao-error-toast">{error}</div>}
    </div>
  );
}

8.4 DaoProposals CSS

/* components/DaoProposals/DaoProposals.css */
.dao-proposal-list {
  max-width: 960px;
  margin: 0 auto;
  padding: 20px;
}

.dao-proposal-list h2 {
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 20px;
}

.dao-filter-bar {
  display: flex;
  gap: 8px;
  margin-bottom: 20px;
}

.dao-filter-btn {
  padding: 8px 16px;
  border: 1px solid #ddd;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.dao-filter-btn:hover {
  background: #f0f0f0;
}

.dao-filter-btn.active {
  background: #1976D2;
  color: #fff;
  border-color: #1976D2;
}

.dao-loading,
.dao-empty,
.dao-error {
  text-align: center;
  padding: 40px;
  color: #888;
}

.dao-error {
  color: #F44336;
}

.dao-proposal-cards {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.dao-proposal-card {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px 20px;
  cursor: pointer;
  transition: all 0.2s;
}

.dao-proposal-card:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  border-color: #1976D2;
}

.dao-card-header {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
}

.dao-proposal-id {
  font-weight: 600;
  color: #666;
  font-size: 13px;
}

.dao-status-badge {
  padding: 2px 10px;
  border-radius: 12px;
  color: #fff;
  font-size: 12px;
}

.dao-contract-badge {
  padding: 2px 8px;
  background: #e3f2fd;
  color: #1976D2;
  border-radius: 4px;
  font-size: 11px;
  font-weight: 500;
}

.dao-card-title {
  font-size: 16px;
  font-weight: 600;
  margin: 0 0 8px;
  color: #222;
}

.dao-card-desc {
  font-size: 14px;
  color: #666;
  margin: 0 0 10px;
  line-height: 1.4;
}

.dao-card-meta {
  display: flex;
  gap: 20px;
  font-size: 13px;
  color: #888;
}

.dao-card-exec {
  margin-top: 8px;
  font-size: 12px;
  color: #1976D2;
  background: #e3f2fd;
  padding: 4px 10px;
  border-radius: 4px;
  font-family: monospace;
}

.treasury-dashboard {
  max-width: 960px;
  margin: 0 auto;
  padding: 20px;
}

.treasury-dashboard h2 {
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 20px;
}

.treasury-summary {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  margin-bottom: 24px;
}

.treasury-balance-card,
.treasury-signers-card {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px;
}

.treasury-balance-card h3,
.treasury-signers-card h3 {
  margin: 0 0 12px;
  font-size: 15px;
  font-weight: 600;
  color: #555;
}

.treasury-balance-amount {
  font-size: 28px;
  font-weight: 700;
  color: #1976D2;
}

.treasury-signers-card p {
  font-size: 14px;
  color: #666;
  margin: 0 0 10px;
}

.signer-list {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.signer-tag {
  padding: 3px 8px;
  background: #f0f0f0;
  border-radius: 4px;
  font-size: 12px;
  font-family: monospace;
  color: #555;
}

.treasury-spend-form {
  background: #f9f9f9;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 24px;
}

.treasury-spend-form h3 {
  margin: 0 0 14px;
  font-size: 16px;
}

.spend-form-row {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.spend-input {
  flex: 1;
  min-width: 180px;
  padding: 10px 14px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
  outline: none;
}

.spend-input:focus {
  border-color: #1976D2;
}

.spend-submit {
  padding: 10px 20px;
  background: #1976D2;
  color: #fff;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  cursor: pointer;
  white-space: nowrap;
}

.spend-submit:hover {
  background: #1565C0;
}

.treasury-proposals h3 {
  font-size: 16px;
  font-weight: 600;
  margin: 0 0 14px;
}

.treasury-proposal-card {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px 20px;
  margin-bottom: 12px;
}

.treasury-prop-header {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
}

.treasury-prop-id {
  font-weight: 600;
  color: #666;
  font-size: 14px;
}

.treasury-prop-status {
  padding: 2px 10px;
  border-radius: 10px;
  font-size: 12px;
  font-weight: 500;
  color: #fff;
}

.treasury-prop-status.pending {
  background: #FF9800;
}

.treasury-prop-status.approved {
  background: #4CAF50;
}

.treasury-prop-status.executed {
  background: #9E9E9E;
}

.treasury-prop-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 12px;
}

.treasury-prop-row {
  display: flex;
  font-size: 14px;
}

.treasury-prop-label {
  width: 80px;
  color: #888;
  flex-shrink: 0;
}

.treasury-prop-value {
  flex: 1;
  font-weight: 500;
  word-break: break-all;
}

.treasury-prop-actions {
  display: flex;
  gap: 8px;
}

.treasury-action-btn {
  padding: 8px 18px;
  border: none;
  border-radius: 6px;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
}

.treasury-action-btn.approve {
  background: #4CAF50;
  color: #fff;
}

.treasury-action-btn.approve:hover {
  background: #388E3C;
}

.treasury-action-btn.execute {
  background: #1976D2;
  color: #fff;
}

.treasury-action-btn.execute:hover {
  background: #1565C0;
}

.dao-error-toast {
  position: fixed;
  bottom: 24px;
  right: 24px;
  background: #F44336;
  color: #fff;
  padding: 12px 20px;
  border-radius: 8px;
  font-size: 14px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  z-index: 1000;
  animation: slideIn 0.3s;
}

@keyframes slideIn {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}


9. 完整示例

9.1 WalletContext 上下文

// context/WalletContext.tsx
import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
} from "react";
import {
  SigningCosmWasmClient,
  CosmWasmClient,
} from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";

interface WalletContextType {
  client: CosmWasmClient | null;
  signingClient: SigningCosmWasmClient | null;
  address: string | null;
  isConnected: boolean;
  connect: () => Promise<void>;
  disconnect: () => void;
}

const WalletContext = createContext<WalletContextType>({
  client: null,
  signingClient: null,
  address: null,
  isConnected: false,
  connect: async () => {},
  disconnect: () => {},
});

export function WalletProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [client, setClient] = useState<CosmWasmClient | null>(null);
  const [signingClient, setSigningClient] =
    useState<SigningCosmWasmClient | null>(null);
  const [address, setAddress] = useState<string | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  const connect = useCallback(async () => {
    try {
      if (!(window as any).keplr) {
        throw new Error("请安装 Keplr 钱包:https://keplr.app");
      }

      await (window as any).keplr.enable(CHAIN_CONFIG.chainId);
      const offlineSigner = (window as any).keplr.getOfflineSigner(
        CHAIN_CONFIG.chainId
      );
      const accounts = await offlineSigner.getAccounts();
      const userAddress = accounts[0].address;

      const cosmClient = await CosmWasmClient.connect(
        CHAIN_CONFIG.rpcEndpoint
      );
      const signClient =
        await SigningCosmWasmClient.connectWithSigner(
          CHAIN_CONFIG.rpcEndpoint,
          offlineSigner,
          {
            gasPrice: {
              denom: CHAIN_CONFIG.denom,
              amount: "1000000000",
            },
          }
        );

      setClient(cosmClient);
      setSigningClient(signClient);
      setAddress(userAddress);
      setIsConnected(true);
    } catch (error: any) {
      console.error("连接失败:", error);
      throw error;
    }
  }, []);

  const disconnect = useCallback(() => {
    setClient(null);
    setSigningClient(null);
    setAddress(null);
    setIsConnected(false);
  }, []);

  useEffect(() => {
    return () => {
      client?.disconnect();
    };
  }, []);

  return (
    <WalletContext.Provider
      value={{
        client,
        signingClient,
        address,
        isConnected,
        connect,
        disconnect,
      }}
    >
      {children}
    </WalletContext.Provider>
  );
}

export const useWallet = () => useContext(WalletContext);

9.2 DAO 合约地址解析

// context/DaoContext.tsx
import React, {
  createContext,
  useContext,
  useState,
  useEffect,
} from "react";
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CONTRACT_ADDRESSES } from "../config/chain";
import { useWallet } from "./WalletContext";

interface DaoContextType {
  daoAddress: string | null;
  treasuryAddress: string | null;
  daoConfig: any;
}

const DaoContext = createContext<DaoContextType>({
  daoAddress: null,
  treasuryAddress: null,
  daoConfig: null,
});

export function DaoProvider({ children }: { children: React.ReactNode }) {
  const { client } = useWallet();
  const [daoAddress, setDaoAddress] = useState<string | null>(null);
  const [treasuryAddress, setTreasuryAddress] =
    useState<string | null>(null);
  const [daoConfig, setDaoConfig] = useState<any>(null);

  useEffect(() => {
    if (!client) return;
    resolveAddresses();
  }, [client]);

  async function resolveAddresses() {
    if (!client) return;
    try {
      const govResult: any = await client.queryContractSmart(
        CONTRACT_ADDRESSES.genesisRegistry,
        { resolve_canonical: { key: "dao_governance" } }
      );
      const treasuryResult: any = await client.queryContractSmart(
        CONTRACT_ADDRESSES.genesisRegistry,
        { resolve_canonical: { key: "foundation_treasury" } }
      );

      setDaoAddress(govResult.address);
      setTreasuryAddress(treasuryResult.address);

      const config: any = await client.queryContractSmart(
        govResult.address,
        { get_config: {} }
      );
      setDaoConfig(config);
    } catch (err) {
      console.error("解析合约地址失败:", err);
    }
  }

  return (
    <DaoContext.Provider
      value={{ daoAddress, treasuryAddress, daoConfig }}
    >
      {children}
    </DaoContext.Provider>
  );
}

export const useDao = () => useContext(DaoContext);

9.3 ConnectWallet 组件

// components/ConnectWallet.tsx
import React from "react";
import { useWallet } from "../context/WalletContext";
import { CHAIN_CONFIG } from "../config/chain";

export function ConnectWallet() {
  const { address, isConnected, connect, disconnect } = useWallet();

  function truncateAddress(addr: string): string {
    return `${addr.slice(0, 10)}...${addr.slice(-8)}`;
  }

  if (isConnected && address) {
    return (
      <div className="wallet-connected">
        <div className="wallet-info">
          <span className="wallet-icon">&#x1F511;</span>
          <span className="wallet-address">
            {truncateAddress(address)}
          </span>
          <span className="wallet-chain">{CHAIN_CONFIG.chainName}</span>
        </div>
        <button className="wallet-disconnect" onClick={disconnect}>
          断开
        </button>
      </div>
    );
  }

  return (
    <button className="wallet-connect" onClick={connect}>
      连接 Keplr 钱包
    </button>
  );
}

9.4 App 主入口(整合示例)

// App.tsx
import React from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import { WalletProvider } from "./context/WalletContext";
import { DaoProvider } from "./context/DaoContext";
import { ConnectWallet } from "./components/ConnectWallet";
import { ProposalList } from "./components/ProposalList/ProposalList";
import { ProposalDetail } from "./components/ProposalDetail/ProposalDetail";
import { CreateProposal } from "./components/ProposalForm/CreateProposal";
import { GovernanceParams } from "./components/GovernanceParams/GovernanceParams";
import { DaoProposalList } from "./components/DaoProposals/DaoProposalList";
import { TreasuryDashboard } from "./components/DaoProposals/TreasuryDashboard";

function HomePage() {
  const navigate = (path: string) => {
    window.history.pushState({}, "", path);
    window.dispatchEvent(new PopStateEvent("popstate"));
  };

  return (
    <div className="home-page">
      <h1>MSG Chain 治理平台</h1>
      <p className="home-desc">
        参与 MSG Chain 的去中心化治理,浏览提案、投票、创建和执行链上决策。
      </p>
      <div className="home-cards">
        <div
          className="home-card"
          onClick={() => navigate("/proposals")}
        >
          <h3>链治理提案</h3>
          <p>浏览和参与 Cosmos SDK x/gov 模块的链上提案</p>
        </div>
        <div
          className="home-card"
          onClick={() => navigate("/dao")}
        >
          <h3>DAO 提案</h3>
          <p>管理 DAO 合约提案和金库多签支出</p>
        </div>
        <div
          className="home-card"
          onClick={() => navigate("/create")}
        >
          <h3>创建提案</h3>
          <p>提交新的文本、参数、社区支出或升级提案</p>
        </div>
        <div
          className="home-card"
          onClick={() => navigate("/params")}
        >
          <h3>治理参数</h3>
          <p>查看和监控链上治理参数配置</p>
        </div>
      </div>
    </div>
  );
}

function ProposalsPage() {
  const navigate = (id: number) => {
    window.history.pushState({}, "", `/proposals/${id}`);
    window.dispatchEvent(new PopStateEvent("popstate"));
  };

  const { client, daoAddress } = useDao();
  const wallet = useWallet();

  return (
    <ProposalList
      status="all"
      onSelect={navigate}
      source="cosmos"
      client={client}
      daoAddress={daoAddress}
    />
  );
}

function ProposalDetailPage() {
  const params = new URLSearchParams(window.location.search);
  const id = Number(params.get("id"));
  const { client, daoAddress } = useDao();
  const wallet = useWallet();

  return (
    <ProposalDetail
      proposalId={id}
      source="cosmos"
      client={client}
      signingClient={wallet.signingClient}
      daoAddress={daoAddress}
      address={wallet.address}
      isConnected={wallet.isConnected}
      onBack={() => window.history.back()}
    />
  );
}

function CreateProposalPage() {
  const wallet = useWallet();

  return (
    <CreateProposal
      signingClient={wallet.signingClient}
      address={wallet.address}
      isConnected={wallet.isConnected}
      mode="cosmos"
      daoAddress={null}
      onCreated={(txHash) => {
        alert(`提案创建成功!交易哈希: ${txHash}`);
        window.location.href = "/proposals";
      }}
    />
  );
}

function GovernanceParamsPage() {
  const { client, daoAddress } = useDao();
  return (
    <GovernanceParams client={client} daoAddress={daoAddress} />
  );
}

function DaoPage() {
  const { client, daoAddress, treasuryAddress } = useDao();
  const wallet = useWallet();
  const [tab, setTab] = useState<"proposals" | "treasury">("proposals");

  return (
    <div className="dao-page">
      <h2>DAO 治理</h2>
      <div className="dao-tabs">
        <button
          className={`dao-tab ${tab === "proposals" ? "active" : ""}`}
          onClick={() => setTab("proposals")}
        >
          提案列表
        </button>
        <button
          className={`dao-tab ${tab === "treasury" ? "active" : ""}`}
          onClick={() => setTab("treasury")}
        >
          金库管理
        </button>
      </div>
      {tab === "proposals" && (
        <DaoProposalList onSelect={(id) => alert(`查看提案 #${id}`)} />
      )}
      {tab === "treasury" && <TreasuryDashboard />}
    </div>
  );
}

function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="app-layout">
      <header className="app-header">
        <Link to="/" className="app-logo">
          MSG 治理
        </Link>
        <nav className="app-nav">
          <Link to="/proposals">提案</Link>
          <Link to="/dao">DAO</Link>
          <Link to="/create">创建</Link>
          <Link to="/params">参数</Link>
        </nav>
        <ConnectWallet />
      </header>
      <main className="app-main">{children}</main>
    </div>
  );
}

export default function App() {
  const [path, setPath] = React.useState(window.location.pathname);

  React.useEffect(() => {
    const handler = () => setPath(window.location.pathname);
    window.addEventListener("popstate", handler);
    return () => window.removeEventListener("popstate", handler);
  }, []);

  function renderPage() {
    if (path.startsWith("/proposals/")) {
      return <ProposalDetailPage />;
    }
    switch (path) {
      case "/":
        return <HomePage />;
      case "/proposals":
        return <ProposalsPage />;
      case "/create":
        return <CreateProposalPage />;
      case "/params":
        return <GovernanceParamsPage />;
      case "/dao":
        return <DaoPage />;
      default:
        return <HomePage />;
    }
  }

  return (
    <WalletProvider>
      <DaoProvider>
        <Layout>
          {renderPage()}
        </Layout>
      </DaoProvider>
    </WalletProvider>
  );
}

9.5 全局样式

/* index.css */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, sans-serif;
  background: #f5f7fa;
  color: #333;
  line-height: 1.5;
}

a {
  text-decoration: none;
  color: inherit;
}

.app-layout {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 24px;
  height: 60px;
  background: #fff;
  border-bottom: 1px solid #e0e0e0;
  position: sticky;
  top: 0;
  z-index: 100;
}

.app-logo {
  font-size: 18px;
  font-weight: 700;
  color: #1976D2;
}

.app-nav {
  display: flex;
  gap: 8px;
}

.app-nav a {
  padding: 8px 14px;
  border-radius: 6px;
  font-size: 14px;
  color: #555;
  transition: all 0.2s;
}

.app-nav a:hover {
  background: #f0f0f0;
  color: #1976D2;
}

.app-main {
  flex: 1;
  padding: 24px;
}

.wallet-connect {
  padding: 8px 18px;
  background: #1976D2;
  color: #fff;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.2s;
}

.wallet-connect:hover {
  background: #1565C0;
}

.wallet-connected {
  display: flex;
  align-items: center;
  gap: 12px;
}

.wallet-info {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 14px;
  background: #e3f2fd;
  border-radius: 6px;
  font-size: 13px;
}

.wallet-address {
  font-family: monospace;
  font-weight: 500;
  color: #1976D2;
}

.wallet-chain {
  padding: 2px 8px;
  background: #bbdefb;
  border-radius: 4px;
  font-size: 11px;
  color: #1565C0;
}

.wallet-disconnect {
  padding: 6px 12px;
  background: none;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 13px;
  cursor: pointer;
  color: #888;
}

.wallet-disconnect:hover {
  border-color: #F44336;
  color: #F44336;
}

.home-page {
  max-width: 800px;
  margin: 40px auto;
  text-align: center;
}

.home-page h1 {
  font-size: 32px;
  font-weight: 700;
  margin-bottom: 12px;
}

.home-desc {
  font-size: 16px;
  color: #666;
  margin-bottom: 40px;
}

.home-cards {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 16px;
}

.home-card {
  background: #fff;
  border: 1px solid #e0e0e0;
  border-radius: 12px;
  padding: 28px 24px;
  cursor: pointer;
  transition: all 0.2s;
  text-align: left;
}

.home-card:hover {
  border-color: #1976D2;
  box-shadow: 0 4px 16px rgba(25, 118, 210, 0.1);
  transform: translateY(-2px);
}

.home-card h3 {
  font-size: 18px;
  margin-bottom: 8px;
  color: #1976D2;
}

.home-card p {
  font-size: 14px;
  color: #888;
  line-height: 1.5;
}

.dao-page {
  max-width: 960px;
  margin: 0 auto;
  padding: 20px;
}

.dao-page h2 {
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 20px;
}

.dao-tabs {
  display: flex;
  gap: 4px;
  border-bottom: 2px solid #eee;
  margin-bottom: 24px;
}

.dao-tab {
  padding: 10px 20px;
  background: none;
  border: none;
  cursor: pointer;
  font-size: 14px;
  color: #666;
  border-bottom: 2px solid transparent;
  margin-bottom: -2px;
  transition: all 0.2s;
}

.dao-tab:hover {
  color: #1976D2;
}

.dao-tab.active {
  color: #1976D2;
  border-bottom-color: #1976D2;
  font-weight: 600;
}

9.6 端到端治理流程示例

// 完整治理流程示例
async function fullGovernanceFlow() {
  // 1. 连接钱包
  const { signingClient, address, client } = useWallet();
  const { daoAddress } = useDao();

  // 2. 创建提案
  const proposalMsg = {
    submit_proposal: {
      title: "增加社区发展基金",
      description:
        "提议从社区池拨款 10,000 MSG 用于支持开发者生态建设。",
      deposit_amount: "10000000",
    },
  };

  const createTx = await signingClient!.execute(
    address!,
    daoAddress!,
    proposalMsg,
    "auto",
    undefined,
    [{ denom: "umsg", amount: "10000000" }]
  );
  console.log("提案创建交易:", createTx.transactionHash);

  // 3. 查询提案
  const newProposal: any = await client!.queryContractSmart(
    daoAddress!,
    { list_proposals: { limit: 1 } }
  );
  const proposalId = newProposal.proposals[0].id;
  console.log("新提案 ID:", proposalId);

  // 4. 投票
  const voteTx = await signingClient!.execute(
    address!,
    daoAddress!,
    { vote: { proposal_id: proposalId, vote: "yes" } },
    "auto"
  );
  console.log("投票交易:", voteTx.transactionHash);

  // 5. 查询计票
  const tally: any = await client!.queryContractSmart(
    daoAddress!,
    { get_proposal_tally: { proposal_id: proposalId } }
  );
  console.log("计票结果:", tally);

  // 6. 执行提案(需投票期结束后)
  const executeTx = await signingClient!.execute(
    address!,
    daoAddress!,
    { execute_proposal: { proposal_id: proposalId } },
    "auto"
  );
  console.log("执行交易:", executeTx.transactionHash);
}

9.7 单元测试示例

// __tests__/governance.test.ts
import {
  getProposalType,
  getProposalStatusLabel,
  formatBalance,
  formatDuration,
  formatPercent,
} from "../utils/format";

describe("Governance Utils", () => {
  test("formatBalance converts umsg to MSG", () => {
    expect(formatBalance("1000000")).toBe("1.00");
    expect(formatBalance("1500000")).toBe("1.50");
    expect(formatBalance("0")).toBe("0.00");
  });

  test("formatDuration handles various durations", () => {
    expect(formatDuration(86400)).toBe("1天");
    expect(formatDuration(90000)).toBe("1天1小时");
    expect(formatDuration(604800)).toBe("7天");
    expect(formatDuration(3600)).toBe("1小时");
  });

  test("formatPercent converts ratio to percentage", () => {
    expect(formatPercent("0.334")).toBe("33.4%");
    expect(formatPercent("0.5")).toBe("50.0%");
    expect(formatPercent("1.0")).toBe("100.0%");
  });

  test("getProposalStatusLabel handles all statuses", () => {
    expect(getProposalStatusLabel("PROPOSAL_STATUS_VOTING_PERIOD")).toBe(
      "投票中"
    );
    expect(getProposalStatusLabel("PROPOSAL_STATUS_PASSED")).toBe("已通过");
    expect(getProposalStatusLabel("PROPOSAL_STATUS_REJECTED")).toBe("已拒绝");
    expect(getProposalStatusLabel("active")).toBe("投票中");
    expect(getProposalStatusLabel("passed")).toBe("已通过");
    expect(getProposalStatusLabel("executed")).toBe("已执行");
  });

  test("getProposalType identifies proposal types", () => {
    expect(
      getProposalType({
        "@type": "/cosmos.gov.v1beta1.TextProposal",
        title: "Test",
        description: "Desc",
      })
    ).toBe("text");

    expect(
      getProposalType({
        "@type": "/cosmos.gov.v1beta1.ParameterChangeProposal",
        title: "Test",
        description: "Desc",
        changes: [],
      })
    ).toBe("parameter_change");
  });
});

10. 部署指南

10.1 环境配置

# .env 文件配置
VITE_CHAIN_ID=msg-chain-1
VITE_RPC_ENDPOINT=https://rpc.msgchain.org
VITE_REST_ENDPOINT=https://api.msgchain.org
VITE_CHAIN_PREFIX=msg
VITE_DENOM=umsg
VITE_GAS_PRICE=1000000000attoMSG
VITE_GENESIS_REGISTRY=msg1registryaddressplaceholder...
VITE_EXPLORER_URL=https://explorer.msgchain.org

10.2 构建与部署

# 1. 安装依赖
npm install

# 2. 构建生产版本
npm run build

# 3. 预览构建结果
npm run preview

# 4. 部署到 IPFS
npm install -g @fleekhq/fleek-cli
fleek sites:init
fleek sites:deploy

# 或部署到 Vercel
npx vercel --prod

# 或部署到 Netlify
npx netlify deploy --prod --dir=dist

10.3 Nginx 配置

# /etc/nginx/sites-available/governance
server {
    listen 80;
    server_name governance.msgchain.org;
    root /var/www/governance-dapp/dist;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location ~* \.(js|css|png|jpg|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    location /api/ {
        proxy_pass https://api.msgchain.org/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

10.4 Docker 部署

# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# docker-compose.yml
version: "3.8"
services:
  governance-dapp:
    build: .
    ports:
      - "80:80"
    environment:
      - VITE_CHAIN_ID=msg-chain-1
      - VITE_RPC_ENDPOINT=https://rpc.msgchain.org
      - VITE_REST_ENDPOINT=https://api.msgchain.org

10.5 安全检查清单

10.6 Gas 费用参考

操作 Gas 消耗 费用 (umsg)
查询提案列表 100k 2,500
查询提案详情 100k 2,500
创建文本提案 200k-300k 5,000-7,500
创建参数修改提案 250k-400k 6,250-10,000
创建社区支出提案 220k-350k 5,500-8,750
创建升级提案 240k-400k 6,000-10,000
提交 DAO 提案 200k-500k 5,000-12,500
投票 (链上) 100k-150k 2,500-3,750
投票 (DAO) 100k-200k 2,500-5,000
执行提案 150k-400k 3,750-10,000
金库提交支出 200k-400k 5,000-10,000
金库批准 100k-150k 2,500-3,750
金库执行 150k-300k 3,750-7,500

10.7 故障排除

常见问题:

  1. Keplr 无法连接

    • 确保安装了 Keplr 浏览器扩展
    • 检查是否添加了 MSG Chain 网络
    • 尝试刷新页面重新连接
  2. 交易失败

    • 检查账户余额是否足够支付 Gas
    • 保证金金额是否达到最小值
    • 提案状态是否正确(不能对已结束的提案投票)
  3. 查询为空

    • 确认 RPC/REST 节点地址正确
    • 检查网络连接是否正常
    • 确保使用的链 ID 为 msg-chain-1
  4. DAO 合约查询失败

    • 确认 genesis_registry_v1 地址正确
    • 验证 DAO 合约已注册到 Registry
    • 检查合约执行消息格式是否正确

本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/