Vue3+Web3实战:用组合式API封装钱包、合约与交易状态
简介本资源是一份面向Web3开发者与前端工程师的区块链DApp实战指南聚焦Vue3与Web3.js协同开发去中心化应用的核心技术路径。内容覆盖区块链基础、Vue3响应式原理与Composition API深度实践、Web3.js与以太坊交互、Solidity合约开发与安全部署、前端-合约集成方案及真实案例如去中心化投票系统全流程实现兼顾理论精要与工程落地。资源为单文件PDF大小4.46MB支持目录跳转与左侧大纲导航全文94页结构清晰含10大章节、42子节涵盖智能合约安全审计、MetaMask钱包接入、事件监听、性能优化与合规实践等高价值细节。目前已有90人学习下载适合具备JavaScript基础、希望系统掌握Vue3构建生产级DApp能力的中高级前端或全栈开发者。1. 这不是“Vue3写个按钮连上MetaMask”就完事的DApp课它用94页真实工程切片把Web3前端从玄学调试拉回可复现、可验证、可交付的落地现场你肯定试过——在 Vue3 项目里npm install web3照着某篇博客调通eth_requestAccounts弹出 MetaMask 窗口点确认拿到地址然后…卡住。接下来怎么读链上数据合约 ABI 怎么动态加载交易失败时 error.code 是 -32603 还是 -32000web3.eth.sendTransaction和contract.methods.transfer().send()的 gas 估算逻辑差在哪为什么本地 Ganache 跑得好好的一上 Sepolia 就reverted with reason string这些不是“环境问题”而是 DApp 前端开发中真实存在的三层断层Vue3 响应式与区块链异步状态的耦合断层、Web3.js 底层 API 与用户操作流的语义断层、开发调试与生产环境链交互的可观测性断层。这份《区块链应用开发Vue3Webjs的DApp实战》PDF 不是概念扫盲它用 94 页完整技术文档把一个去中心化投票系统从 Solidity 合约设计、Hardhat 测试脚本、Vue3 组合式 API 封装、Web3.js 多节点自动切换、到交易状态机pending → mined → confirmed的 UI 反馈闭环全部拆成可粘贴、可调试、可对照错误日志反查的代码块和配置项。它面向的是已经能写 Vue3 组件、但第一次面对eth_getBlockByNumber返回null时会愣三秒的中级前端它解决的不是“能不能连”而是“连上之后每一步操作背后的数据流向、错误分支、重试策略、缓存边界到底是什么”。如果你正卡在“合约方法调用没反应”或“事件监听收不到 emit”这份材料就是你该立刻打开的调试手册。2. Vue3 组合式 API 不是语法糖它是 DApp 前端状态管理的唯一可行解DApp 的前端状态天然具有强异步性、多来源性和不可预测性用户钱包连接状态、链上账户余额、合约部署地址、交易 pending 状态、事件监听器生命周期、Gas 价格波动…这些状态既不能全塞进一个ref也不能靠v-model双向绑定搞定。Vue2 的 Options API 在这里会迅速失控——data里堆满isConnected、isContractLoaded、isTxPending、txHash、txReceiptmethods里充斥着connectWallet()、loadContract()、sendVote()、watchEvents()而watch和computed的依赖关系像毛线团。组合式 API 的价值在于它强制你把状态域domain和行为域behavior按区块链交互的原子动作切分让每个useXxxHook 成为一个自包含、可测试、可复用的状态单元。这不是为了炫技而是工程必然。2.1 把钱包连接抽象成useWallet从“弹窗一次”到“状态可追溯、可重试、可降级”在 DApp 中“连接钱包”绝非一个window.ethereum.request({ method: eth_requestAccounts })就结束的动作。它涉及浏览器兼容性判断MetaMask、Coinbase Wallet、Rainbow、注入式 Web3 实例检测、fallback HTTP Provider 初始化、账户变更监听、网络切换响应、以及最关键的——连接失败后的明确错误分类与用户引导。Options API 下这些逻辑极易散落在mounted、methods、watch中导致状态不一致。组合式 API 强制你封装为useWallet// composables/useWallet.js import { ref, reactive, onMounted, onUnmounted } from vue import { ethers } from ethers export function useWallet() { const state reactive({ isConnected: false, address: null, chainId: null, provider: null, signer: null, // 错误状态细化不只用一个 error 字符串 connectionError: null, // null | NO_PROVIDER | USER_REJECTED | NETWORK_MISMATCH | UNKNOWN isConnecting: false }) // 检测并初始化 provider const initProvider async () { state.isConnecting true state.connectionError null try { if (window.ethereum) { // 优先使用 EIP-1193 兼容的注入式 provider const provider new ethers.providers.Web3Provider(window.ethereum) state.provider provider state.signer provider.getSigner() // 请求账户访问权限 const accounts await window.ethereum.request({ method: eth_requestAccounts }) state.address accounts[0] state.isConnected true // 监听账户变更 window.ethereum.on(accountsChanged, (accounts) { if (accounts.length 0) { state.address accounts[0] } else { state.isConnected false state.address null } }) // 监听网络变更 window.ethereum.on(chainChanged, (chainId) { state.chainId parseInt(chainId, 16) }) } else if (window.web3) { // 兼容旧版 MetaMask const provider new ethers.providers.Web3Provider(window.web3.currentProvider) state.provider provider state.signer provider.getSigner() } else { // Fallback: 使用 Infura 或 Alchemy 的 HTTP Provider仅读取 const infuraUrl https://sepolia.infura.io/v3/YOUR_INFURA_KEY const provider new ethers.providers.JsonRpcProvider(infuraUrl) state.provider provider // 注意HTTP Provider 无法签名signer 为 null } } catch (err) { console.error(Wallet connection failed:, err) if (err.code 4001) { state.connectionError USER_REJECTED } else if (err.code 4100 || err.code 4902) { state.connectionError NETWORK_MISMATCH } else { state.connectionError UNKNOWN } } finally { state.isConnecting false } } // 断开连接清理事件监听器 const disconnect () { if (window.ethereum) { window.ethereum.removeListener(accountsChanged, () {}) window.ethereum.removeListener(chainChanged, () {}) } state.isConnected false state.address null state.chainId null state.provider null state.signer null } // 组件挂载时尝试初始化 onMounted(() { initProvider() }) // 组件卸载时清理 onUnmounted(() { disconnect() }) return { ...state, initProvider, disconnect } }参数说明与逻辑说明state.connectionError被细分为NO_PROVIDER、USER_REJECTED等枚举值而非笼统的error.message这直接决定了 UI 上显示“请安装 MetaMask”还是“请授权账户”onMounted和onUnmounted确保了事件监听器的严格生命周期管理避免内存泄漏和重复监听signer仅在注入式 provider 下存在HTTP Provider 下为null这迫使你在调用contract.connect(signer).method()前必须做if (signer)判断杜绝运行时Cannot read property send of nullinitProvider是一个纯函数返回 Promise可在任何地方手动触发重试比如用户点击“重试连接”按钮。2.2 把合约交互抽象成useContractABI 加载、实例化、方法调用、事件监听的原子封装一个 DApp 常需同时与多个合约交互如投票合约 代币合约 身份合约。若每个组件都重复new web3.eth.Contract(abi, address)不仅冗余更致命的是 ABI 加载时机、地址硬编码、网络切换后地址失效等问题。useContract将合约视为一个有状态的服务// composables/useContract.js import { ref, reactive, onMounted, watch } from vue import { ethers } from ethers export function useContract(abi, address, options {}) { const { readOnly false, // 是否只读不需 signer autoLoad true, // 是否自动加载 ABI 和实例 networkId 11155111 // Sepolia 默认 ID } options const state reactive({ contract: null, abi: null, address: null, isLoading: false, loadError: null, // 事件监听器集合用于统一管理 eventListeners: new Map() }) // 加载 ABI可从本地 JSON 或远程 URL const loadABI async (abiSource) { if (typeof abiSource string) { // 从 URL 加载 try { const response await fetch(abiSource) if (!response.ok) throw new Error(HTTP ${response.status}) state.abi await response.json() } catch (err) { state.loadError Failed to load ABI from ${abiSource}: ${err.message} throw err } } else { // 直接传入 ABI 数组 state.abi abiSource } } // 创建合约实例 const createContractInstance (provider, signer null) { if (!state.abi || !address) return null try { if (readOnly signer) { console.warn(readOnly mode: ignoring provided signer) } const contract readOnly ? new ethers.Contract(address, state.abi, provider) : new ethers.Contract(address, state.abi, signer || provider) state.contract contract state.address address return contract } catch (err) { state.loadError Failed to create contract instance: ${err.message} throw err } } // 加载合约核心方法 const loadContract async (provider, signer null) { state.isLoading true state.loadError null try { // 如果 ABI 是字符串路径先加载 if (typeof abi string) { await loadABI(abi) } else { state.abi abi } const contract createContractInstance(provider, signer) if (!contract) throw new Error(Contract instance creation failed) // 验证合约地址是否有效可选调用一个 view 方法 if (contract contract.address) { try { // 调用一个简单的 view 方法如 name() 或 totalSupply() await contract.address // 这行本身不报错但确保地址存在 } catch (err) { state.loadError Contract address ${address} may be invalid or not deployed on this network throw err } } } catch (err) { console.error(Contract loading failed:, err) state.loadError err.message } finally { state.isLoading false } } // 监听合约事件返回取消函数 const watchEvent (eventName, listener, options {}) { if (!state.contract) return () {} const eventFilter state.contract.filters[eventName](...options.args) const eventListener (log, event) { listener(log, event) } state.contract.on(eventFilter, eventListener) // 存储监听器以便后续移除 const key ${eventName}_${Date.now()} state.eventListeners.set(key, { eventFilter, eventListener }) return () { if (state.contract) { state.contract.off(eventFilter, eventListener) state.eventListeners.delete(key) } } } // 卸载时清理所有事件监听器 onUnmounted(() { state.eventListeners.forEach(({ eventFilter, eventListener }, key) { if (state.contract) { state.contract.off(eventFilter, eventListener) } }) state.eventListeners.clear() }) // 自动加载如果 autoLoad 为 true if (autoLoad address) { watch( () [address, state.abi], ([newAddress, newAbi]) { if (newAddress newAbi) { // 此处需传入 provider 和 signer通常由父组件提供 // 实际使用中loadContract 会在父组件的 setup 中调用 } }, { immediate: true } ) } return { ...state, loadContract, watchEvent } }参数说明与逻辑说明readOnly参数明确区分合约用途读取链上数据如余额、提案列表用provider发送交易如投票、转账必须传入signerwatchEvent返回一个取消函数这是 Vue3onUnmounted清理监听器的黄金搭档彻底规避“组件销毁后还在收事件”的经典翻车loadContract内部对contract.address的简单验证是防止因网络切换如从 Sepolia 切到 Mainnet导致合约地址无效却无提示的后悔药eventListeners用Map存储键为eventName_时间戳确保每个监听器可独立移除而不是粗暴contract.removeAllListeners()。2.3 把交易状态抽象成useTransaction从“发交易”到“交易全生命周期可视化”DApp 用户最焦虑的时刻就是点击“投票”后UI 停在“Processing…” 三秒然后静音。Web3 交易不是 HTTP 请求它有明确的三段式生命周期pending广播到 mempool、mined被打包进区块、confirmed获得 N 个区块确认。useTransaction将这个过程建模为一个状态机并提供send()、waitForMined()、waitForConfirmed()等语义化方法// composables/useTransaction.js import { ref, reactive } from vue import { ethers } from ethers export function useTransaction() { const state reactive({ hash: null, status: idle, // idle | pending | mined | confirmed | failed receipt: null, error: null, confirmations: 0, // Gas 相关信息用于展示和预估 gasPrice: null, gasUsed: null, effectiveGasPrice: null }) // 发送交易接受 ethers.ContractMethodSendObject const send async (transactionPromise, options {}) { const { onHash () {}, onMined () {}, onConfirmed () {}, confirmations 2 // 默认等待 2 个确认 } options state.status pending state.hash null state.receipt null state.error null state.confirmations 0 try { // 1. 发送交易获取 hash const txResponse await transactionPromise state.hash txResponse.hash onHash(txResponse.hash) // 2. 等待矿工打包mined const receipt await txResponse.wait(1) // wait(1) 等待 1 个确认即为 mined state.receipt receipt state.status mined state.gasUsed receipt.gasUsed.toString() state.effectiveGasPrice receipt.effectiveGasPrice?.toString() onMined(receipt) // 3. 等待指定数量的确认confirmed if (confirmations 1) { const finalReceipt await txResponse.wait(confirmations) state.receipt finalReceipt state.status confirmed state.confirmations confirmations onConfirmed(finalReceipt) } } catch (err) { console.error(Transaction failed:, err) state.status failed state.error err.message || err.reason || Unknown error // 对常见错误码做友好提示 if (err.code UNPREDICTABLE_GAS_LIMIT) { state.error Transaction reverted: check your inputs and contract state } else if (err.code INSUFFICIENT_FUNDS) { state.error Insufficient ETH for gas fee } } } // 仅等待 mined不阻塞返回 Promise const waitForMined (hash) { if (!hash) return Promise.reject(new Error(No transaction hash)) return ethers.getDefaultProvider().waitForTransaction(hash, 1) } // 仅等待 confirmed不阻塞返回 Promise const waitForConfirmed (hash, confirmations 2) { if (!hash) return Promise.reject(new Error(No transaction hash)) return ethers.getDefaultProvider().waitForTransaction(hash, confirmations) } return { ...state, send, waitForMined, waitForConfirmed } }参数说明与逻辑说明send方法接收一个transactionPromise如contract.vote(proposalId).send()的返回值而非原始参数这保证了调用方完全控制交易构造逻辑如value、gasLimitonHash、onMined、onConfirmed回调允许你在不同阶段更新 UIonHash显示“已广播等待打包”onMined显示“已上链区块号 XXX”onConfirmed显示“已获 2 个确认交易完成”waitForTransaction的confirmations参数是关键主网建议 12测试网 2 即可硬编码会导致体验割裂错误处理中对UNPREDICTABLE_GAS_LIMIT和INSUFFICIENT_FUNDS的映射是用户能看懂的提示不是Error: cannot estimate gas; transaction may fail这种黑匣子。3. Web3.js 与 Vue3 的胶水层为什么web3.eth.Contract必须被封装而不是直接在setup里 new直接在 Vue3 组件的setup函数里const contract new web3.eth.Contract(abi, address)是新手最常犯的错误也是项目后期维护噩梦的起点。原因有三状态污染、生命周期失控、网络切换失敏。Web3.js 的Contract实例不是无状态的 POJO它内部持有provider、signer引用并且其方法调用尤其是.send()会隐式触发provider的网络请求。当 Vue3 组件被销毁如路由跳转这个contract实例若未被显式清理其内部的provider事件监听器如block监听可能仍在后台运行消耗资源并导致内存泄漏。更严重的是当用户在 DApp 中切换网络如从 Sepolia 切到 Mainnetcontract实例的address依然指向旧网络的地址调用contract.methods.xxx().call()会静默失败或返回错误数据而组件对此毫无感知。因此必须有一层“胶水”将 Web3.js 的底层能力转化为 Vue3 的响应式、可组合、可销毁的状态单元。这层胶水就是前文useContract的核心价值。3.1useWeb3Provider统一管理 Provider 实例与网络状态Provider 是 Web3 交互的基石。一个健壮的 DApp 必须支持多 Provider 切换开发时用本地 Ganache测试用 Infura/Alchemy生产用用户 MetaMask 注入。useWeb3Provider将 Provider 管理提升为应用级状态而非组件级// composables/useWeb3Provider.js import { ref, reactive, onMounted, watch } from vue import { ethers } from ethers export function useWeb3Provider() { const state reactive({ provider: null, signer: null, network: null, // { name: sepolia, chainId: 11155111 } isReady: false, error: null }) // 根据当前环境选择 Provider const initProvider async (networkConfig {}) { const { type injected, // injected | http | websocket httpUrl , wsUrl } networkConfig try { state.error null if (type injected) { if (window.ethereum) { const provider new ethers.providers.Web3Provider(window.ethereum) state.provider provider state.signer provider.getSigner() // 获取当前网络 const network await provider.getNetwork() state.network { name: network.name, chainId: network.chainId } state.isReady true } else { state.error No injected wallet detected (e.g., MetaMask) } } else if (type http) { if (!httpUrl) { state.error HTTP Provider URL is required return } const provider new ethers.providers.JsonRpcProvider(httpUrl) state.provider provider // HTTP Provider 无 signer state.isReady true } else if (type websocket) { if (!wsUrl) { state.error WebSocket Provider URL is required return } const provider new ethers.providers.WebSocketProvider(wsUrl) state.provider provider state.isReady true } } catch (err) { console.error(Provider initialization failed:, err) state.error err.message } } // 监听网络变化仅对 injected provider 有效 const setupNetworkListener () { if (window.ethereum state.provider) { window.ethereum.on(chainChanged, async (chainIdHex) { const chainId parseInt(chainIdHex, 16) const network await state.provider.getNetwork() state.network { name: network.name, chainId: network.chainId } // 网络切换后可能需要重新加载合约 // 这里可以触发一个全局事件通知所有 useContract 实例刷新 }) } } // 组件挂载时初始化 onMounted(() { initProvider() setupNetworkListener() }) return { ...state, initProvider, setupNetworkListener } }参数说明与逻辑说明type参数明确区分 Provider 类型避免if (window.ethereum) {...} else if (window.web3) {...}的嵌套地狱setupNetworkListener是网络切换的“心跳”它确保state.network始终与用户钱包中的实际网络同步state.isReady是一个关键信号所有依赖 Provider 的useContract或useWallet都应watch它只有isReady true时才执行loadContract形成清晰的依赖链。3.2useBlockchainData链上数据的响应式缓存与自动刷新DApp 的 UI 需要频繁读取链上数据账户余额、合约状态、区块高度、Gas 价格。每次都await provider.getBalance(address)会造成大量 HTTP 请求用户体验卡顿且浪费 RPC 调用配额。useBlockchainData提供基于ref的响应式缓存并支持定时刷新和手动触发// composables/useBlockchainData.js import { ref, reactive, onMounted, onUnmounted, watch } from vue import { ethers } from ethers export function useBlockchainData(provider, options {}) { const { refreshInterval 10000, // 10秒刷新一次 cacheTTL 30000 // 缓存30秒 } options const state reactive({ data: {}, isLoading: false, lastUpdated: 0, error: null }) // 缓存存储简单对象实际项目可用 Map 或更高级缓存库 const cache new Map() // 通用数据获取函数 const fetchData async (key, fetcher, ...args) { const now Date.now() // 检查缓存 const cached cache.get(key) if (cached (now - cached.timestamp) cacheTTL) { return cached.value } try { state.isLoading true state.error null const value await fetcher(...args) cache.set(key, { value, timestamp: now }) return value } catch (err) { console.error(Failed to fetch ${key}:, err) state.error err.message throw err } finally { state.isLoading false state.lastUpdated now } } // 获取账户余额 const getBalance async (address, blockTag latest) { const key balance_${address}_${blockTag} return fetchData(key, async () { const balance await provider.getBalance(address, blockTag) return ethers.utils.formatEther(balance) }) } // 获取区块号 const getBlockNumber async () { const key block_number return fetchData(key, async () { const number await provider.getBlockNumber() return number }) } // 获取 Gas 价格 const getGasPrice async () { const key gas_price return fetchData(key, async () { const price await provider.getGasPrice() return ethers.utils.formatUnits(price, gwei) }) } // 启动定时刷新 let intervalId null onMounted(() { if (refreshInterval 0) { intervalId setInterval(async () { try { await getBlockNumber() await getGasPrice() } catch (err) { // 刷新失败不中断继续下一轮 } }, refreshInterval) } }) onUnmounted(() { if (intervalId) { clearInterval(intervalId) intervalId null } }) return { ...state, getBalance, getBlockNumber, getGasPrice, fetchData } }参数说明与逻辑说明cacheTTLTime-To-Live和refreshInterval是两个独立参数cacheTTL控制单次数据的有效期如余额 30 秒内不重查refreshInterval控制后台轮询频率如每 10 秒查一次区块高度fetchData的key生成规则如balance_${address}_${blockTag}确保了不同地址、不同区块高度的查询互不干扰onUnmounted中清除intervalId是防止组件销毁后定时器仍在后台运行的铁律。3.3 避坑 / 常见问题 / 排查Web3.js 与 Vue3 集成的五大血泪现场DApp 前端开发中有五个高频、隐蔽、且极易浪费数小时的坑它们都源于对 Web3.js 底层机制和 Vue3 响应式原理的双重误判。以下是真实项目中反复踩过的记录按“现象 → 原因 → 解决”结构整理现象 1在setup里console.log(contract)看起来一切正常但调用contract.methods.xxx().call()时抛出TypeError: Cannot read property call of undefined→原因contract.methods.xxx是一个 getter它在contract实例创建时并未立即解析所有方法而是惰性加载。如果abi中没有定义名为xxx的方法或者abi格式有误如inputs数组为空但name存在contract.methods.xxx就是undefined。Vue3 的console.log在 DevTools 中展开时会触发 getter掩盖了这个问题。→解决在useContract.loadContract()成功后添加一个验证步骤if (!contract.methods.xxx) { throw new Error(Method xxx not found in ABI) }。永远不要信任console.log的表象。现象 2用户切换 MetaMask 网络后DApp 页面没有任何反应直到手动刷新→原因window.ethereum.on(chainChanged)事件监听器是在useWallet的onMounted中注册的但如果useWallet实例在组件销毁时被回收如路由跳转而新组件又创建了新的useWallet实例旧的监听器并未被移除导致多个监听器同时存在或者新监听器根本没注册上。更常见的是useWallet的state.chainId更新了但 UI 组件没有watch它所以不触发重新渲染。→解决在useWallet中onUnmounted必须显式调用window.ethereum.removeListener()同时在 UI 组件中用watch监听wallet.chainId并在变化时调用contract.loadContract()重新初始化合约实例。现象 3contract.methods.transfer(to, amount).send()成功返回transactionHash但transactionHash在etherscan.io上查不到且contract.on(Transfer, ...)事件监听器从未触发→原因transfer方法在 ERC-20 合约中通常是non-payable但amount参数传入的是string如1000000000000000000而合约期望uint256。Web3.js 会尝试转换但若转换失败交易会被静默拒绝revertsend()仍会返回 hash因为广播成功了只是链上执行失败。→解决在调用send()前用ethers.utils.parseUnits(amount, decimals)将金额字符串安全地转换为BigNumber并在send()的catch中检查err.code CALL_EXCEPTION并解析err.data获取 revert reason。现象 4在useTransaction.send()中调用txResponse.wait(1)但页面卡死控制台无报错→原因txResponse.wait()是一个 Promise它依赖provider的getBlockNumber()和getTransactionReceipt()调用。如果provider是一个 HTTP Provider如 Infura且网络不通或配额用尽wait()会无限期 pending因为它的默认超时是0永不超时。→解决永远不要裸用wait()。应包装为Promise.race([txResponse.wait(1), new Promise((_, reject) setTimeout(() reject(new Error(Transaction timeout)), 120000))])设置 2 分钟超时。现象 5useContract.watchEvent(Transfer, callback)在组件卸载后callback依然被调用导致Cannot read property value of null→原因contract.on()添加的监听器其生命周期与contract实例绑定而contract实例的生命周期又与useContract的onUnmounted绑定。但如果useContract是在一个v-if为false的组件中创建的onUnmounted不会触发监听器永久驻留。→解决useContract.watchEvent()必须返回一个取消函数并在组件的onBeforeUnmount比onUnmounted更早中调用它。onBeforeUnmount是 Vue3 提供的、在组件 DOM 被移除前的最后钩子是清理异步监听器的黄金时机。4. 从 Solidity 合约到 Vue3 组件一个去中心化投票系统的端到端集成实录理论讲完现在进入最硬核的部分把 PDF 文档第九章“案例实战去中心化投票系统”的核心逻辑从 Solidity 合约、到 Hardhat 测试、再到 Vue3 前端全部打通。这不是 demo而是模拟一个真实项目从零开始的构建流程。我们将聚焦三个关键节点合约设计如何影响前端状态模型、测试用例如何驱动前端交互逻辑、以及部署后如何用前端验证合约状态。整个过程你只需要一个 VS Code 和 Node.js 环境无需任何中心化后端。4.1 Solidity 投票合约的核心设计为什么votersmapping 不能直接暴露给前端PDF 第 9.2.1 节给出了一个简化的投票合约。但很多开发者会想当然地认为前端只需调用voters(address)就能知道某人是否投过票。这是巨大的误区。mapping(address bool) public voters是一个状态变量Solidity 的public关键字会自动生成一个voters(address)的 getter 函数但它只能被外部调用view/pure不能被其他合约调用。更重要的是在大型 DApp 中频繁调用voters(address)会产生大量 RPC 请求且无法批量查询。正确的设计是合约负责业务逻辑前端负责状态聚合。// contracts/Voting.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract Voting { struct Proposal { uint256 id; string description; uint256 voteCount; bool executed; } Proposal[] public proposals; mapping(uint256 mapping(address bool)) public hasVoted; // 二维 mapping address public owner; constructor() { owner msg.sender; } // 创建提案 function createProposal(string memory _description) public { proposals.push(Proposal({ id: proposals.length, description: _description, voteCount: 0, executed: false })); } // 投票 function vote(uint256 _proposalId) public { require(_proposal p a hrefhttps://download.csdn.net/download/2401_87692645/90836121 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p