当前位置: 首页 > news >正文

去中心化投票系统开发教程 第四章:前端开发与用户界面

第四章:前端开发与用户界面

在这里插入图片描述

🚀 引言

在前一章中,我们成功实现了去中心化投票系统的智能合约。现在,我们需要一个用户友好的界面,让用户能够方便地与这些合约交互。在本章中,我们将使用React和ethers.js构建一个现代化的前端应用,为我们的投票系统提供直观的用户体验。

无论是创建投票、添加候选人、授予投票权还是进行投票,我们的目标是让这些操作对用户来说都简单明了。同时,我们也会确保应用的安全性和可靠性,让用户能够信任这个系统。

📝 前端需求分析

在开始编码之前,让我们先明确我们的前端应用需要满足哪些需求:

功能需求

  1. 连接钱包:用户可以连接MetaMask或其他Web3钱包
  2. 查看投票列表:显示所有可用的投票议题
  3. 查看投票详情:显示特定投票的详细信息和候选人
  4. 创建投票:管理员可以创建新的投票议题
  5. 添加候选人:管理员可以为投票添加候选人
  6. 管理投票权:管理员可以授予或撤销用户的投票权
  7. 投票:有投票权的用户可以进行投票
  8. 查看结果:任何人都可以查看投票结果

非功能需求

  1. 用户体验:简洁明了的界面,操作流程清晰
  2. 响应式设计:适配不同尺寸的屏幕
  3. 性能:快速加载和响应
  4. 安全性:安全地处理用户数据和交易
  5. 错误处理:友好的错误提示和处理机制

🛠️ 技术栈选择

对于我们的前端应用,我们将使用以下技术栈:

  1. React:用于构建用户界面的JavaScript库
  2. ethers.js:与以太坊区块链交互的库
  3. React Router:处理应用内导航
  4. Chakra UI:提供现成的UI组件
  5. React Query:管理异步状态和缓存
  6. Vite:快速的前端构建工具

🏗️ 项目结构

让我们先创建项目的基本结构:

mkdir -p frontend/src/{components,hooks,pages,utils,contexts}
cd frontend
npm init -y
npm install react react-dom react-router-dom ethers @chakra-ui/react @emotion/react @emotion/styled framer-motion react-query
npm install -D vite @vitejs/plugin-react

然后,我们创建以下文件结构:

frontend/
├── public/
│   └── index.html
├── src/
│   ├── components/
│   │   ├── Layout.jsx
│   │   ├── Navbar.jsx
│   │   ├── BallotCard.jsx
│   │   ├── CandidateList.jsx
│   │   ├── VoteForm.jsx
│   │   ├── CreateBallotForm.jsx
│   │   └── AddCandidateForm.jsx
│   ├── contexts/
│   │   └── Web3Context.jsx
│   ├── hooks/
│   │   ├── useContract.js
│   │   ├── useBallots.js
│   │   └── useVoting.js
│   ├── pages/
│   │   ├── Home.jsx
│   │   ├── BallotDetails.jsx
│   │   ├── CreateBallot.jsx
│   │   └── Admin.jsx
│   ├── utils/
│   │   ├── constants.js
│   │   └── helpers.js
│   ├── App.jsx
│   └── main.jsx
├── vite.config.js
└── package.json

💻 实现前端应用

配置Vite

首先,让我们配置Vite。创建vite.config.js文件:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';export default defineConfig({plugins: [react()],server: {port: 3000,open: true},build: {outDir: 'dist',sourcemap: true}
});

创建入口文件

创建public/index.html

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>去中心化投票系统</title><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
</head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script>
</body>
</html>

创建src/main.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import App from './App';// 创建主题
const theme = extendTheme({fonts: {heading: 'Inter, sans-serif',body: 'Inter, sans-serif',},colors: {brand: {50: '#e6f7ff',100: '#b3e0ff',200: '#80caff',300: '#4db3ff',400: '#1a9dff',500: '#0080ff',600: '#0066cc',700: '#004d99',800: '#003366',900: '#001a33',},},
});// 创建查询客户端
const queryClient = new QueryClient();// 渲染应用
ReactDOM.createRoot(document.getElementById('root')).render(<React.StrictMode><ChakraProvider theme={theme}><QueryClientProvider client={queryClient}><App /></QueryClientProvider></ChakraProvider></React.StrictMode>
);

创建Web3上下文

创建src/contexts/Web3Context.jsx

import React, { createContext, useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
import { useToast } from '@chakra-ui/react';
import { VOTING_SYSTEM_ABI, VOTING_SYSTEM_ADDRESS } from '../utils/constants';export const Web3Context = createContext();export const Web3Provider = ({ children }) => {const [provider, setProvider] = useState(null);const [signer, setSigner] = useState(null);const [contract, setContract] = useState(null);const [account, setAccount] = useState(null);const [isOwner, setIsOwner] = useState(false);const [hasVotingRights, setHasVotingRights] = useState(false);const [isConnecting, setIsConnecting] = useState(false);const [chainId, setChainId] = useState(null);const toast = useToast();// 初始化提供者useEffect(() => {const initProvider = async () => {if (window.ethereum) {try {const provider = new ethers.providers.Web3Provider(window.ethereum);setProvider(provider);const { chainId } = await provider.getNetwork();setChainId(chainId);// 监听账户变化window.ethereum.on('accountsChanged', handleAccountsChanged);// 监听链变化window.ethereum.on('chainChanged', (chainId) => {window.location.reload();});return provider;} catch (error) {console.error('Error initializing provider:', error);toast({title: '连接错误',description: '无法连接到以太坊网络',status: 'error',duration: 5000,isClosable: true,});}} else {toast({title: '未检测到钱包',description: '请安装MetaMask或其他Web3钱包',status: 'warning',duration: 5000,isClosable: true,});}};initProvider();return () => {if (window.ethereum) {window.ethereum.removeListener('accountsChanged', handleAccountsChanged);}};}, []);// 处理账户变化const handleAccountsChanged = async (accounts) => {if (accounts.length === 0) {setAccount(null);setSigner(null);setContract(null);setIsOwner(false);setHasVotingRights(false);} else if (accounts[0] !== account) {setAccount(accounts[0]);if (provider) {const signer = provider.getSigner();setSigner(signer);const contract = new ethers.Contract(VOTING_SYSTEM_ADDRESS,VOTING_SYSTEM_ABI,signer);setContract(contract);try {const owner = await contract.owner();setIsOwner(accounts[0].toLowerCase() === owner.toLowerCase());const hasRights = await contract.hasVotingRights(accounts[0]);setHasVotingRights(hasRights);} catch (error) {console.error('Error checking account status:', error);}}}};// 连接钱包const connectWallet = useCallback(async () => {if (!provider) return;setIsConnecting(true);try {const accounts = await window.ethereum.request({method: 'eth_requestAccounts',});handleAccountsChanged(accounts);toast({title: '钱包已连接',description: `已连接到账户: ${accounts[0].substring(0, 6)}...${accounts[0].substring(38)}`,status: 'success',duration: 3000,isClosable: true,});} catch (error) {console.error('Error connecting wallet:', error);toast({title: '连接失败',description: error.message,status: 'error',duration: 5000,isClosable: true,});} finally {setIsConnecting(false);}}, [provider]);// 断开钱包连接const disconnectWallet = useCallback(() => {setAccount(null);setSigner(null);setContract(null);setIsOwner(false);setHasVotingRights(false);toast({title: '钱包已断开',status: 'info',duration: 3000,isClosable: true,});}, []);return (<Web3Context.Providervalue={{provider,signer,contract,account,isOwner,hasVotingRights,connectWallet,disconnectWallet,isConnecting,chainId,}}>{children}</Web3Context.Provider>);
};export default Web3Provider;

创建自定义Hook

创建src/hooks/useContract.js

import { useContext } from 'react';
import { Web3Context } from '../contexts/Web3Context';export const useContract = () => {const { contract, account, isOwner, hasVotingRights } = useContext(Web3Context);return {contract,account,isOwner,hasVotingRights,};
};

创建src/hooks/useBallots.js

import { useQuery, useMutation, useQueryClient } from 'react-query';
import { useContract } from './useContract';
import { ethers } from 'ethers';
import { useToast } from '@chakra-ui/react';export const useBallots = () => {const { contract, account, isOwner } = useContract();const queryClient = useQueryClient();const toast = useToast();// 获取所有投票const { data: ballots, isLoading, error } = useQuery(['ballots'],async () => {if (!contract) return [];const ballotCount = await contract.ballotCount();const ballots = [];for (let i = 0; i < ballotCount; i++) {const [title, description, startTime, endTime, finalized, creator] = await contract.getBallotDetails(i);const candidateCount = await contract.candidateCounts(i);const candidates = [];for (let j = 0; j < candidateCount; j++) {const [name, info, voteCount] = await contract.getCandidateDetails(i, j);candidates.push({id: j,name,info,voteCount: voteCount.toNumber(),});}let hasVoted = false;if (account) {hasVoted = await contract.hasVotedInBallot(i, account);}ballots.push({id: i,title,description,startTime: new Date(startTime.toNumber() * 1000),endTime: new Date(endTime.toNumber() * 1000),finalized,creator,candidates,hasVoted,});}return ballots;},{enabled: !!contract,refetchInterval: 10000, // 每10秒刷新一次});// 获取单个投票const getBallot = async (id) => {if (!contract) return null;const [title, description, startTime, endTime, finalized, creator] = await contract.getBallotDetails(id);const candidateCount = await contract.candidateCounts(id);const candidates = [];for (let j = 0; j < candidateCount; j++) {const [name, info, voteCount] = await contract.getCandidateDetails(id, j);candidates.push({id: j,name,info,voteCount: voteCount.toNumber(),});}let hasVoted = false;if (account) {hasVoted = await contract.hasVotedInBallot(id, account);}return {id,title,description,startTime: new Date(startTime.toNumber() * 1000),endTime: new Date(endTime.toNumber() * 1000),finalized,creator,candidates,hasVoted,};};// 创建投票const createBallotMutation = useMutation(async ({ title, description, startTime, endTime }) => {if (!contract || !isOwner) throw new Error('未授权操作');const tx = await contract.createBallot(title,description,Math.floor(startTime.getTime() / 1000),Math.floor(endTime.getTime() / 1000));await tx.wait();return tx;},{onSuccess: () => {queryClient.invalidateQueries(['ballots']);toast({title: '创建成功',description: '投票已成功创建',status: 'success',duration: 5000,isClosable: true,});},onError: (error) => {toast({title: '创建失败',description: error.message,status: 'error',duration: 5000,isClosable: true,});},});// 添加候选人const addCandidateMutation = useMutation(async ({ ballotId, name, info }) => {if (!contract || !isOwner) throw new Error('未授权操作');const tx = await contract.addCandidate(ballotId, name, info);await tx.wait();return tx;},{onSuccess: () => {queryClient.invalidateQueries(['ballots']);toast({title: '添加成功',description: '候选人已成功添加',status: 'success',duration: 5000,isClosable: true,});},onError: (error) => {toast({title: '添加失败',description: error.message,status: 'error',duration: 5000,isClosable: true,});},});// 结束投票const finalizeBallotMutation = useMutation(async (ballotId) => {if (!contract || !isOwner) throw new Error('未授权操作');const tx = await contract.finalizeBallot(ballotId);await tx.wait();return tx;},{onSuccess: () => {queryClient.invalidateQueries(['ballots']);toast({title: '操作成功',description: '投票已成功结束',status: 'success',duration: 5000,isClosable: true,});},onError: (error) => {toast({title: '操作失败',description: error.message,status: 'error',duration: 5000,isClosable: true,});},});return {ballots,isLoading,error,getBallot,createBallot: createBallotMutation.mutate,isCreating: createBallotMutation.isLoading,addCandidate: addCandidateMutation.mutate,isAddingCandidate: addCandidateMutation.isLoading,finalizeBallot: finalizeBallotMutation.mutate,isFinalizing: finalizeBallotMutation.isLoading,};
};

创建src/hooks/useVoting.js

import { useMutation, useQueryClient } from 'react-query';
import { useContract } from './useContract';
import { useToast } from '@chakra-ui/react';export const useVoting = () => {const { contract, account, isOwner, hasVotingRights } = useContract();const queryClient = useQueryClient();const toast = useToast();// 投票const voteMutation = useMutation(async ({ ballotId, candidateId }) => {if (!contract) throw new Error('未连接到合约');if (!account) throw new Error('请先连接钱包');if (!hasVotingRights) throw new Error('您没有投票权');const tx = await contract.vote(ballotId, candidateId);await tx.wait();return tx;},{onSuccess: () => {queryClient.invalidateQueries(['ballots']);toast({title: '投票成功',description: '您的投票已成功记录',status: 'success',duration: 5000,isClosable: true,});},onError: (error) => {toast({title: '投票失败',description: error.message,status: 'error',duration: 5000,isClosable: true,});},});// 添加投票者const addVoterMutation = useMutation(async (address) => {if (!contract || !isOwner) throw new Error('未授权操作');const tx = await contract.addVoter(address);await tx.wait();return tx;},{onSuccess: () => {toast({title: '添加成功',description: '投票者已成功添加',status: 'success',duration: 5000,isClosable: true,});},onError: (error) => {toast({title: '添加失败',description: error.message,status: 'error',duration: 5000,isClosable: true,});},});// 批量添加投票者const addVotersMutation = useMutation(async (addresses) => {if (!contract || !isOwner) throw new Error('未授权操作');const tx = await contract.addVoters(addresses);await tx.wait();return tx;},{onSuccess: () => {toast({title: '添加成功',description: '投票者已成功批量添加',status: 'success',duration: 5000,isClosable: true,});},onError: (error) => {toast({title: '添加失败',description: error.message,status: 'error',duration: 5000,isClosable: true,});},});return {vote: voteMutation.mutate,isVoting: voteMutation.isLoading,addVoter: addVoterMutation.mutate,isAddingVoter: addVoterMutation.isLoading,addVoters: addVotersMutation.mutate,isAddingVoters: addVotersMutation.isLoading,hasVotingRights,};
};

创建组件

创建src/components/Layout.jsx

import React from 'react';
import { Box, Container } from '@chakra-ui/react';
import Navbar from './Navbar';const Layout = ({ children }) => {return (<Box minH="100vh" bg="gray.50"><Navbar /><Container maxW="container.xl" py={8}>{children}</Container></Box>);
};export default Layout;

创建src/components/Navbar.jsx

import React, { useContext } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {Box,Flex,Text,Button,Stack,Link,useColorModeValue,useDisclosure,
} from '@chakra-ui/react';
import { HamburgerIcon, CloseIcon } from '@chakra-ui/icons';
import { Web3Context } from '../contexts/Web3Context';const NavLink = ({ children, to }) => (<Linkas={RouterLink}px={2}py={1}rounded={'md'}_hover={{textDecoration: 'none',bg: useColorModeValue('gray.200', 'gray.700'),}}to={to}>{children}</Link>
);const Navbar = () => {const { isOpen, onToggle } = useDisclosure();const { account, connectWallet, disconnectWallet, isConnecting, isOwner } = useContext(Web3Context);return (<Box><Flexbg={useColorModeValue('white', 'gray.800')}color={useColorModeValue('gray.600', 'white')}minH={'60px'}py={{ base: 2 }}px={{ base: 4 }}borderBottom={1}borderStyle={'solid'}borderColor={useColorModeValue('gray.200', 'gray.900')}align={'center'}boxShadow="sm"><Flexflex={{ base: 1, md: 'auto' }}ml={{ base: -2 }}display={{ base: 'flex', md: 'none' }}><ButtononClick={onToggle}variant={'ghost'}aria-label={'Toggle Navigation'}>{isOpen ? <CloseIcon w={3} h={3} /> : <HamburgerIcon w={5} h={5} />}</Button></Flex><Flex flex={{ base: 1 }} justify={{ base: 'center', md: 'start' }}><TexttextAlign={useColorModeValue('left', 'center')}fontFamily={'heading'}color={useColorModeValue('gray.800', 'white')}fontWeight="bold"as={RouterLink}to="/">去中心化投票系统</Text><Flex display={{ base: 'none', md: 'flex' }} ml={10}><Stack direction={'row'} spacing={4}><NavLink to="/">首页</NavLink>{isOwner && <NavLink to="/create">创建投票</NavLink>}{isOwner && <NavLink to="/admin">管理</NavLink>}</Stack></Flex></Flex><Stackflex={{ base: 1, md: 0 }}justify={'flex-end'}direction={'row'}spacing={6}>{account ? (<><Text display={{ base: 'none', md: 'flex' }} alignItems="center">{`${account.substring(0, 6)}...${account.substring(38)}`}</Text><ButtonfontSize={'sm'}fontWeight={400}variant={'outline'}onClick={disconnectWallet}>断开连接</Button></>) : (<Buttondisplay={{ base: 'none', md: 'inline-flex' }}fontSize={'sm'}fontWeight={600}color={'white'}bg={'brand.500'}_hover={{bg: 'brand.400',}}onClick={connectWallet}isLoading={isConnecting}>连接钱包</Button>)}</Stack></Flex></Box>);
};export default Navbar;

创建src/components/BallotCard.jsx

import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {Box,Heading,Text,Stack,Badge,Button,Flex,useColorModeValue,
} from '@chakra-ui/react';const BallotCard = ({ ballot }) => {const now = new Date();const isActive = now >= ballot.startTime && now <= ballot.endTime && !ballot.finalized;const isUpcoming = now < ballot.startTime;const isEnded = now > ballot.endTime || ballot.finalized;const formatDate = (date) => {return new Intl.DateTimeFormat('zh-CN', {year: 'numeric',month: 'short',day: 'numeric',hour: '2-digit',minute: '2-digit',}).format(date);};return (<BoxmaxW={'445px'}w={'full'}bg={useColorModeValue('white', 'gray.900')}boxShadow={'md'}rounded={'md'}p={6}overflow={'hidden'}transition="transform 0.3s"_hover={{ transform: 'translateY(-5px)' }}><Stack><Flex justify="space-between" align="center"><Headingcolor={useColorModeValue('gray.700', 'white')}fontSize={'xl'}fontFamily={'body'}noOfLines={1}>{ballot.title}</Heading>{isActive && <Badge colorScheme="green">进行中</Badge>}{isUpcoming && <Badge colorScheme="blue">即将开始</Badge>}{isEnded && <Badge colorScheme="red">已结束</Badge>}</Flex><Text color={'gray.500'} noOfLines={2}>{ballot.description}</Text></Stack><Stack mt={4} direction={'row'} spacing={4} align={'center'}><Stack direction={'column'} spacing={0} fontSize={'sm'}><Text fontWeight={600}>开始时间</Text><Text color={'gray.500'}>{formatDate(ballot.startTime)}</Text></Stack><Stack direction={'column'} spacing={0} fontSize={'sm'}><Text fontWeight={600}>结束时间</Text><Text color={'gray.500'}>{formatDate(ballot.endTime)}</Text></Stack></Stack><Buttonas={RouterLink}to={`/ballot/${ballot.id}`}mt={6}w={'full'}bg={'brand.500'}color={'white'}_hover={{bg: 'brand.400',}}>查看详情</Button></Box>);
};export default BallotCard;

创建src/components/CandidateList.jsx

import React, { useContext } from 'react';
import {Box,Heading,Text,Stack,Progress,Radio,RadioGroup,Button,useColorModeValue,Flex,
} from '@chakra-ui/react';
import { Web3Context } from '../contexts/Web3Context';
import { useVoting } from '../hooks/useVoting';const CandidateList = ({ ballot, onVote }) => {const [selectedCandidate, setSelectedCandidate] = React.useState('');const { account, hasVotingRights } = useContext(Web3Context);const { vote, isVoting } = useVoting();const totalVotes = ballot.candidates.reduce((sum, candidate) => sum + candidate.voteCount, 0);const handleVote = () => {if (selectedCandidate) {vote({ballotId: ballot.id,candidateId: parseInt(selectedCandidate),});}};const now = new Date();const canVote = account && hasVotingRights && !ballot.hasVoted && now >= ballot.startTime && now <= ballot.endTime && !ballot.finalized;return (<Boxbg={useColorModeValue('white', 'gray.700')}borderRadius="lg"p={6}boxShadow="md"><Heading size="md" mb={4}>候选人列表</Heading>{ballot.candidates.length === 0 ? (<Text>暂无候选人</Text>) : (<Stack spacing={4}>{canVote ? (<RadioGroup onChange={setSelectedCandidate} value={selectedCandidate}><Stack>{ballot.candidates.map((candidate) => (<Box key={candidate.id} p={3} borderWidth="1px" borderRadius="md"><Radio value={candidate.id.toString()}><Text fontWeight="bold">{candidate.name}</Text><Text fontSize="sm" color="gray.500">{candidate.info}</Text></Radio></Box>))}</Stack></RadioGroup>) : (ballot.candidates.map((candidate) => {const percentage = totalVotes > 0 ? (candidate.voteCount / totalVotes) * 100 : 0;return (<Box key={candidate.id} p={3} borderWidth="1px" borderRadius="md"><Flex justify="space-between"><Text fontWeight="bold">{candidate.name}</Text><Text>{candidate.voteCount} 票</Text></Flex><Text fontSize="sm" color="gray.500" mb={2}>{candidate.info}</Text><Progressvalue={percentage}size="sm"colorScheme="brand"borderRadius="full"/><Text fontSize="xs" textAlign="right" mt={1}>{percentage.toFixed(1)}%</Text></Box>);}))}{canVote && (<ButtoncolorScheme="brand"isLoading={isVoting}isDisabled={!selectedCandidate}onClick={handleVote}mt={4}>提交投票</Button>)}{account && !hasVotingRights && (<Text color="red.500">您没有投票权限</Text>)}{account && hasVotingRights && ballot.hasVoted && (<Text color="green.500">您已经在此投票中投过票了</Text>)}{!account && (<Text color="orange.500">请连接钱包以参与投票</Text>)}</Stack>)}</Box>);
};export default CandidateList;

创建src/components/VoteForm.jsx

import React, { useState } from 'react';
import {Box,Button,FormControl,FormLabel,Radio,RadioGroup,Stack,Text,useToast,
} from '@chakra-ui/react';
import { useVoting } from '../hooks/useVoting';const VoteForm = ({ ballot }) => {const [selectedCandidate, setSelectedCandidate] = useState('');const { vote, isVoting } = useVoting();const toast = useToast();const handleSubmit = (e) => {e.preventDefault();if (!selectedCandidate) {toast({title: '请选择候选人',status: 'warning',duration: 3000,isClosable: true,});return;}vote({ballotId: ballot.id,candidateId: parseInt(selectedCandidate),});};return (<Box as="form" onSubmit={handleSubmit} p={4} borderWidth="1px" borderRadius="lg"><Text fontSize="xl" fontWeight="bold" mb={4}>投票</Text><FormControl isRequired mb={4}><FormLabel>选择候选人</FormLabel><RadioGroup onChange={setSelectedCandidate} value={selectedCandidate}><Stack>{ballot.candidates.map((candidate) => (<Radio key={candidate.id} value={candidate.id.toString()}>{candidate.name} - {candidate.info}</Radio>))}</Stack></RadioGroup></FormControl><Buttontype="submit"colorScheme="brand"isLoading={isVoting}isDisabled={!selectedCandidate}>提交投票</Button></Box>);
};export default VoteForm;

创建src/components/CreateBallotForm.jsx

import React, { useState } from 'react';
import {Box,Button,FormControl,FormLabel,Input,Textarea,Stack,Heading,FormErrorMessage,useToast,
} from '@chakra-ui/react';
import { useBallots } from '../hooks/useBallots';const CreateBallotForm = () => {const [title, setTitle] = useState('');const [description, setDescription] = useState('');const [startDate, setStartDate] = useState('');const [startTime, setStartTime] = useState('');const [endDate, setEndDate] = useState('');const [endTime, setEndTime] = useState('');const [errors, setErrors] = useState({});const { createBallot, isCreating } = useBallots();const toast = useToast();const validateForm = () => {const newErrors = {};if (!title.trim()) {newErrors.title = '请输入投票标题';}if (!description.trim()) {newErrors.description = '请输入投票描述';}if (!startDate || !startTime) {newErrors.startTime = '请选择开始时间';}if (!endDate || !endTime) {newErrors.endTime = '请选择结束时间';}const start = new Date(`${startDate}T${startTime}`);const end = new Date(`${endDate}T${endTime}`);const now = new Date();if (start < now) {newErrors.startTime = '开始时间不能早于当前时间';}if (end <= start) {newErrors.endTime = '结束时间必须晚于开始时间';}setErrors(newErrors);return Object.keys(newErrors).length === 0;};const handleSubmit = (e) => {e.preventDefault();if (!validateForm()) {return;}const startDateTime = new Date(`${startDate}T${startTime}`);const endDateTime = new Date(`${endDate}T${endTime}`);createBallot({title,description,startTime: startDateTime,endTime: endDateTime,});// 重置表单setTitle('');setDescription('');setStartDate('');setStartTime('');setEndDate('');setEndTime('');};return (<Box as="form" onSubmit={handleSubmit} p={6} borderWidth="1px" borderRadius="lg" bg="white"><Heading size="md" mb={4}>创建新投票</Heading><Stack spacing={4}><FormControl isRequired isInvalid={!!errors.title}><FormLabel>投票标题</FormLabel><Inputvalue={title}onChange={(e) => setTitle(e.target.value)}placeholder="输入投票标题"/><FormErrorMessage>{errors.title}</FormErrorMessage></FormControl><FormControl isRequired isInvalid={!!errors.description}><FormLabel>投票描述</FormLabel><Textareavalue={description}onChange={(e) => setDescription(e.target.value)}placeholder="输入投票描述"rows={4}/><FormErrorMessage>{errors.description}</FormErrorMessage></FormControl><Stack direction={{ base: 'column', md: 'row' }} spacing={4}><FormControl isRequired isInvalid={!!errors.startTime}><FormLabel>开始日期</FormLabel><Inputtype="date"value={startDate}onChange={(e) => setStartDate(e.target.value)}/></FormControl><FormControl isRequired isInvalid={!!errors.startTime}><FormLabel>开始时间</FormLabel><Inputtype="time"value={startTime}onChange={(e) => setStartTime(e.target.value)}/><FormErrorMessage>{errors.startTime}</FormErrorMessage></FormControl></Stack><Stack direction={{ base: 'column', md: 'row' }} spacing={4}><FormControl isRequired isInvalid={!!errors.endTime}><FormLabel>结束日期</FormLabel><Inputtype="date"value={endDate}onChange={(e) => setEndDate(e.target.value)}/></FormControl><FormControl isRequired isInvalid={!!errors.endTime}><FormLabel>结束时间</FormLabel><Inputtype="time"value={endTime}onChange={(e) => setEndTime(e.target.value)}/><FormErrorMessage>{errors.endTime}</FormErrorMessage></FormControl></Stack><Buttontype="submit"colorScheme="brand"isLoading={isCreating}mt={4}>创建投票</Button></Stack></Box>);
};export default CreateBallotForm;

创建src/components/AddCandidateForm.jsx

import React, { useState } from 'react';
import {Box,Button,FormControl,FormLabel,Input,Textarea,Stack,Heading,FormErrorMessage,
} from '@chakra-ui/react';
import { useBallots } from '../hooks/useBallots';const AddCandidateForm = ({ ballotId }) => {const [name, setName] = useState('');const [info, setInfo] = useState('');const [errors, setErrors] = useState({});const { addCandidate, isAddingCandidate } = useBallots();const validateForm = () => {const newErrors = {};if (!name.trim()) {newErrors.name = '请输入候选人名称';}if (!info.trim()) {newErrors.info = '请输入候选人信息';}setErrors(newErrors);return Object.keys(newErrors).length === 0;};const handleSubmit = (e) => {e.preventDefault();if (!validateForm()) {return;}addCandidate({ballotId,name,info,});// 重置表单setName('');setInfo('');};return (<Box as="form" onSubmit={handleSubmit} p={6} borderWidth="1px" borderRadius="lg" bg="white"><Heading size="md" mb={4}>添加候选人</Heading><Stack spacing={4}><FormControl isRequired isInvalid={!!errors.name}><FormLabel>候选人名称</FormLabel><Inputvalue={name}onChange={(e) => setName(e.target.value)}placeholder="输入候选人名称"/><FormErrorMessage>{errors.name}</FormErrorMessage></FormControl><FormControl isRequired isInvalid={!!errors.info}><FormLabel>候选人信息</FormLabel><Textareavalue={info}onChange={(e) => setInfo(e.target.value)}placeholder="输入候选人信息"rows={3}/><FormErrorMessage>{errors.info}</FormErrorMessage></FormControl><Buttontype="submit"colorScheme="brand"isLoading={isAddingCandidate}mt={2}>添加候选人</Button></Stack></Box>);
};export default AddCandidateForm;

创建页面组件

创建src/pages/Home.jsx

import React from 'react';
import {Box,Heading,Text,SimpleGrid,Spinner,Center,Alert,AlertIcon,AlertTitle,AlertDescription,
} from '@chakra-ui/react';
import Layout from '../components/Layout';
import BallotCard from '../components/BallotCard';
import { useBallots } from '../hooks/useBallots';const Home = () => {const { ballots, isLoading, error } = useBallots();if (isLoading) {return (<Layout><Center h="50vh"><Spinner size="xl" color="brand.500" /></Center></Layout>);}if (error) {return (<Layout><Alert status="error" borderRadius="md"><AlertIcon /><AlertTitle mr={2}>加载失败</AlertTitle><AlertDescription>{error.message}</AlertDescription></Alert></Layout>);}return (<Layout><Box mb={8}><Heading as="h1" size="xl" mb={2}>去中心化投票系统</Heading><Text color="gray.600">透明、安全、不可篡改的区块链投票平台</Text></Box>{ballots && ballots.length > 0 ? (<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={10}>{ballots.map((ballot) => (<BallotCard key={ballot.id} ballot={ballot} />))}</SimpleGrid>) : (<Center h="30vh"><Text fontSize="lg" color="gray.500">暂无投票,请稍后再来查看</Text></Center>)}</Layout>);
};export default Home;

创建src/pages/BallotDetails.jsx

import React, { useEffect, useState, useContext } from 'react';
import { useParams } from 'react-router-dom';
import {Box,Heading,Text,Badge,Stack,Button,Spinner,Center,Alert,AlertIcon,Flex,Divider,useToast,
} from '@chakra-ui/react';
import Layout from '../components/Layout';
import CandidateList from '../components/CandidateList';
import AddCandidateForm from '../components/AddCandidateForm';
import { useBallots } from '../hooks/useBallots';
import { Web3Context } from '../contexts/Web3Context';const BallotDetails = () => {const { id } = useParams();const { getBallot, finalizeBallot, isFinalizing } = useBallots();const { isOwner } = useContext(Web3Context);const [ballot, setBallot] = useState(null);const [loading, setLoading] = useState(true);const [error, setError] = useState(null);const toast = useToast();useEffect(() => {const fetchBallot = async () => {try {const data = await getBallot(parseInt(id));setBallot(data);} catch (err) {setError(err.message);toast({title: '加载失败',description: err.message,status: 'error',duration: 5000,isClosable: true,});} finally {setLoading(false);}};fetchBallot();// 每10秒刷新一次const interval = setInterval(fetchBallot, 10000);return () => clearInterval(interval);}, [id, getBallot, toast]);const handleFinalize = () => {finalizeBallot(parseInt(id));};if (loading) {return (<Layout><Center h="50vh"><Spinner size="xl" color="brand.500" /></Center></Layout>);}if (error || !ballot) {return (<Layout><Alert status="error" borderRadius="md"><AlertIcon /><Text>无法加载投票详情</Text></Alert></Layout>);}const now = new Date();const isActive = now >= ballot.startTime && now <= ballot.endTime && !ballot.finalized;const isUpcoming = now < ballot.startTime;const isEnded = now > ballot.endTime || ballot.finalized;const formatDate = (date) => {return new Intl.DateTimeFormat('zh-CN', {year: 'numeric',month: 'long',day: 'numeric',hour: '2-digit',minute: '2-digit',}).format(date);};return (<Layout><Box mb={8}><Flex justify="space-between" align="center" mb={2}><Heading as="h1" size="xl">{ballot.title}</Heading>{isActive && <Badge colorScheme="green" fontSize="md" p={1}>进行中</Badge>}{isUpcoming && <Badge colorScheme="blue" fontSize="md" p={1}>即将开始</Badge>}{isEnded && <Badge colorScheme="red" fontSize="md" p={1}>已结束</Badge>}</Flex><Text color="gray.600" mb={4}>{ballot.description}</Text><Stack direction={{ base: 'column', md: 'row' }} spacing={8} mb={4}><Box><Text fontWeight="bold">开始时间</Text><Text>{formatDate(ballot.startTime)}</Text></Box><Box><Text fontWeight="bold">结束时间</Text><Text>{formatDate(ballot.endTime)}</Text></Box><Box><Text fontWeight="bold">创建者</Text><Text>{`${ballot.creator.substring(0, 6)}...${ballot.creator.substring(38)}`}</Text></Box></Stack></Box><Divider mb={8} /><CandidateList ballot={ballot} />{isOwner && !ballot.finalized && (<><Box mt={8}><AddCandidateForm ballotId={parseInt(id)} /></Box>{isEnded && !ballot.finalized && (<Buttonmt={8}colorScheme="red"onClick={handleFinalize}isLoading={isFinalizing}>结束投票并公布结果</Button>)}</>)}</Layout>);
};export default BallotDetails;

创建src/pages/CreateBallot.jsx

import React, { useContext, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {Box,Heading,Alert,AlertIcon,AlertTitle,AlertDescription,
} from '@chakra-ui/react';
import Layout from '../components/Layout';
import CreateBallotForm from '../components/CreateBallotForm';
import { Web3Context } from '../contexts/Web3Context';const CreateBallot = () => {const { isOwner, account } = useContext(Web3Context);const navigate = useNavigate();useEffect(() => {// 如果用户不是管理员,重定向到首页if (account && !isOwner) {navigate('/');}}, [account, isOwner, navigate]);if (!account) {return (<Layout><Alert status="warning" borderRadius="md"><AlertIcon /><AlertTitle mr={2}>未连接钱包</AlertTitle><AlertDescription>请先连接您的钱包</AlertDescription></Alert></Layout>);}if (!isOwner) {return (<Layout><Alert status="error" borderRadius="md"><AlertIcon /><AlertTitle mr={2}>访问被拒绝</AlertTitle><AlertDescription>只有管理员可以创建投票</AlertDescription></Alert></Layout>);}return (<Layout><Box mb={8}><Heading as="h1" size="xl" mb={2}>创建新投票</Heading></Box><CreateBallotForm /></Layout>);
};export default CreateBallot;

创建src/pages/Admin.jsx

import React, { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {Box,Heading,Text,Button,Input,FormControl,FormLabel,FormErrorMessage,Stack,Textarea,Alert,AlertIcon,AlertTitle,AlertDescription,Divider,useToast,
} from '@chakra-ui/react';
import Layout from '../components/Layout';
import { Web3Context } from '../contexts/Web3Context';
import { useVoting } from '../hooks/useVoting';
import { ethers } from 'ethers';const Admin = () => {const { isOwner, account } = useContext(Web3Context);const { addVoter, isAddingVoter, addVoters, isAddingVoters } = useVoting();const navigate = useNavigate();const toast = useToast();const [voterAddress, setVoterAddress] = useState('');const [voterAddresses, setVoterAddresses] = useState('');const [errors, setErrors] = useState({});useEffect(() => {// 如果用户不是管理员,重定向到首页if (account && !isOwner) {navigate('/');}}, [account, isOwner, navigate]);const validateSingleAddress = (address) => {try {return ethers.utils.isAddress(address);} catch (error) {return false;}};const handleAddVoter = (e) => {e.preventDefault();const newErrors = {};if (!voterAddress.trim()) {newErrors.voterAddress = '请输入地址';} else if (!validateSingleAddress(voterAddress)) {newErrors.voterAddress = '无效的以太坊地址';}setErrors(newErrors);if (Object.keys(newErrors).length === 0) {addVoter(voterAddress);setVoterAddress('');}};const handleAddVoters = (e) => {e.preventDefault();const newErrors = {};if (!voterAddresses.trim()) {newErrors.voterAddresses = '请输入地址列表';} else {const addresses = voterAddresses.split('\n').map(addr => addr.trim()).filter(addr => addr !== '');const invalidAddresses = addresses.filter(addr => !validateSingleAddress(addr));if (invalidAddresses.length > 0) {newErrors.voterAddresses = `包含 ${invalidAddresses.length} 个无效地址`;} else if (addresses.length === 0) {newErrors.voterAddresses = '请至少输入一个有效地址';}}setErrors(newErrors);if (Object.keys(newErrors).length === 0) {const addresses = voterAddresses.split('\n').map(addr => addr.trim()).filter(addr => addr !== '');addVoters(addresses);setVoterAddresses('');}};if (!account) {return (<Layout><Alert status="warning" borderRadius="md"><AlertIcon /><AlertTitle mr={2}>未连接钱包</AlertTitle><AlertDescription>请先连接您的钱包</AlertDescription></Alert></Layout>);}if (!isOwner) {return (<Layout><Alert status="error" borderRadius="md"><AlertIcon /><AlertTitle mr={2}>访问被拒绝</AlertTitle><AlertDescription>只有管理员可以访问此页面</AlertDescription></Alert></Layout>);}return (<Layout><Box mb={8}><Heading as="h1" size="xl" mb={2}>管理面板</Heading><Text color="gray.600">管理投票系统的用户和权限</Text></Box><Stack spacing={8}><Box p={6} borderWidth="1px" borderRadius="lg" bg="white"><Heading size="md" mb={4}>添加单个投票者</Heading><form onSubmit={handleAddVoter}><FormControl isRequired isInvalid={!!errors.voterAddress}><FormLabel>投票者地址</FormLabel><Inputvalue={voterAddress}onChange={(e) => setVoterAddress(e.target.value)}placeholder="输入以太坊地址"/><FormErrorMessage>{errors.voterAddress}</FormErrorMessage></FormControl><Buttontype="submit"colorScheme="brand"mt={4}isLoading={isAddingVoter}>添加投票者</Button></form></Box><Divider /><Box p={6} borderWidth="1px" borderRadius="lg" bg="white"><Heading size="md" mb={4}>批量添加投票者</Heading><form onSubmit={handleAddVoters}><FormControl isRequired isInvalid={!!errors.voterAddresses}><FormLabel>投票者地址列表(每行一个地址)</FormLabel><Textareavalue={voterAddresses}onChange={(e) => setVoterAddresses(e.target.value)}placeholder="输入以太坊地址,每行一个"rows={6}/><FormErrorMessage>{errors.voterAddresses}</FormErrorMessage></FormControl><Buttontype="submit"colorScheme="brand"mt={4}isLoading={isAddingVoters}>批量添加投票者</Button></form></Box></Stack></Layout>);
};export default Admin;

创建主应用组件

创建src/App.jsx

import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Web3Provider from './contexts/Web3Context';
import Home from './pages/Home';
import BallotDetails from './pages/BallotDetails';
import CreateBallot from './pages/CreateBallot';
import Admin from './pages/Admin';const App = () => {return (<Web3Provider><Router><Routes><Route path="/" element={<Home />} /><Route path="/ballot/:id" element={<BallotDetails />} /><Route path="/create" element={<CreateBallot />} /><Route path="/admin" element={<Admin />} /></Routes></Router></Web3Provider>);
};export default App;

创建工具函数

创建src/utils/constants.js

// 投票系统合约ABI
export const VOTING_SYSTEM_ABI = [// 这里是合约ABI,从编译后的合约JSON文件中获取// 以下是示例,实际使用时需要替换为真实的ABI"function owner() view returns (address)","function ballotCount() view returns (uint256)","function candidateCounts(uint256) view returns (uint256)","function hasVotingRights(address) view returns (bool)","function hasVotedInBallot(uint256, address) view returns (bool)","function getBallotDetails(uint256) view returns (string, string, uint256, uint256, bool, address)","function getCandidateDetails(uint256, uint256) view returns (string, string, uint256)","function createBallot(string, string, uint256, uint256) returns (uint256)","function addCandidate(uint256, string, string) returns (uint256)","function vote(uint256, uint256)","function finalizeBallot(uint256)","function addVoter(address)","function addVoters(address[])","event BallotCreated(uint256 indexed ballotId, string title, address creator)","event CandidateAdded(uint256 indexed ballotId, uint256 indexed candidateId, string name)","event VoteCast(uint256 indexed ballotId, uint256 indexed candidateId, address voter)","event BallotFinalized(uint256 indexed ballotId)"
];// 投票系统合约地址
export const VOTING_SYSTEM_ADDRESS = "0x..."; // 部署后的合约地址

创建src/utils/helpers.js

import { ethers } from 'ethers';// 格式化地址,显示前6位和后4位
export const formatAddress = (address) => {if (!address) return '';return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
};// 格式化日期时间
export const formatDateTime = (timestamp) => {if (!timestamp) return '';const date = new Date(timestamp);return new Intl.DateTimeFormat('zh-CN', {year: 'numeric',month: 'short',day: 'numeric',hour: '2-digit',minute: '2-digit',}).format(date);
};// 检查地址是否有效
export const isValidAddress = (address) => {try {return ethers.utils.isAddress(address);} catch (error) {return false;}
};// 计算投票百分比
export const calculatePercentage = (votes, totalVotes) => {if (totalVotes === 0) return 0;return (votes / totalVotes) * 100;
};// 获取投票状态
export const getBallotStatus = (startTime, endTime, finalized) => {const now = new Date();if (finalized) {return { status: 'ended', label: '已结束', color: 'red' };}if (now < startTime) {return { status: 'upcoming', label: '即将开始', color: 'blue' };}if (now >= startTime && now <= endTime) {return { status: 'active', label: '进行中', color: 'green' };}return { status: 'ended', label: '已结束', color: 'red' };
};

本章总结

在本章中,我们完成了去中心化投票系统的前端开发,创建了一个用户友好的界面,使用户能够与我们的智能合约进行交互。我们的前端实现了以下功能:

  1. 钱包连接:使用Web3Context提供钱包连接功能,支持MetaMask等以太坊钱包。

  2. 投票管理

    • 查看所有投票列表
    • 查看单个投票详情
    • 创建新投票(仅管理员)
    • 添加候选人(仅管理员)
    • 结束投票并公布结果(仅管理员)
  3. 投票参与

    • 为候选人投票
    • 查看投票结果和统计数据
  4. 用户管理

    • 添加单个投票者(仅管理员)
    • 批量添加投票者(仅管理员)

我们使用了以下技术和库来构建前端:

  • React:用于构建用户界面的JavaScript库
  • React Router:处理应用程序的路由
  • Chakra UI:提供现代、可访问的UI组件
  • ethers.js:与以太坊区块链交互的库
  • React Query:用于数据获取和缓存

前端的架构采用了组件化和钩子(Hooks)的方式,将UI和业务逻辑分离,使代码更加模块化和可维护。我们创建了自定义钩子来封装与智能合约的交互逻辑,使组件能够专注于渲染和用户交互。

进一步改进的方向

虽然我们已经实现了一个功能完整的去中心化投票系统,但仍有一些可以改进的地方:

  1. 用户体验优化

    • 添加加载状态和过渡动画
    • 实现更友好的错误处理和提示
    • 优化移动端适配
  2. 功能扩展

    • 支持多种投票类型(单选、多选、排名等)
    • 添加投票结果可视化图表
    • 实现投票委托功能
  3. 安全性增强

    • 添加更多的输入验证和安全检查
    • 实现更复杂的权限管理系统
  4. 性能优化

    • 实现更高效的数据获取和缓存策略
    • 优化大量数据的渲染性能
  5. 测试和部署

    • 添加单元测试和集成测试
    • 优化部署流程,支持不同的网络环境

通过这些改进,我们可以进一步提升系统的可用性、安全性和性能,为用户提供更好的投票体验。

在下一章中,我们将探讨如何测试和部署我们的去中心化投票系统,确保它在真实环境中可靠运行。


文章转载自:

http://9giN67rW.snjpj.cn
http://ivrtF0D9.snjpj.cn
http://kYOJ1XhC.snjpj.cn
http://CWfSL9cI.snjpj.cn
http://MJunTkWK.snjpj.cn
http://hYmazztl.snjpj.cn
http://1xar9ync.snjpj.cn
http://bzZMaIoW.snjpj.cn
http://OmxvAaUt.snjpj.cn
http://go0YKNGs.snjpj.cn
http://XaRN9xBB.snjpj.cn
http://XisPOicB.snjpj.cn
http://lfGV5iIs.snjpj.cn
http://dEcirSqz.snjpj.cn
http://DHpLCPSz.snjpj.cn
http://EndEBDnS.snjpj.cn
http://61bnJz6O.snjpj.cn
http://A90hky99.snjpj.cn
http://bDdxfmiP.snjpj.cn
http://GXn2XA8I.snjpj.cn
http://p8QlxXeY.snjpj.cn
http://uypdAFxb.snjpj.cn
http://2Ks6oAut.snjpj.cn
http://VjBnf3Hk.snjpj.cn
http://yUcV3J5t.snjpj.cn
http://sps3uIA8.snjpj.cn
http://Tw70KQWM.snjpj.cn
http://iEtwrB4p.snjpj.cn
http://oFc1DaRc.snjpj.cn
http://LlRZhewR.snjpj.cn
http://www.dtcms.com/a/371499.html

相关文章:

  • 使用csi-driver-nfs实现K8S动态供给
  • linux内核 - 获取内核日志时间戳的方法
  • 从0到1学习Vue框架Day01
  • K8S-Pod(下)
  • RocketMQ事务消息:分布式系统的金融级可靠性保障
  • OSPF基础部分知识点
  • k8s核心技术-Helm
  • 《P2341 [USACO03FALL / HAOI2006] 受欢迎的牛 G》
  • GitHub App 架构解析与最佳实践
  • PPP(点对点协议)详细讲解
  • 人工智能优化SEO关键词的实战策略
  • Git高阶实战:Rebase与Cherry-pick重塑你的工作流
  • 【机器学习】通过tensorflow搭建神经网络进行气温预测
  • 基于 Django+Vue3 的 AI 海报生成平台开发博客(海报模块专项)
  • 线程间通信
  • 文件上传之读取文件内容保存到ES
  • 图神经网络分享系列-SDNE(Structural Deep Network Embedding) (一)
  • sentinel限流常见的几种算法以及优缺点
  • 【贪心算法】day6
  • CSS(展示效果)
  • 基于原神游戏物品系统小demo制作思路
  • docker,本地目录挂载
  • The Xilinx 7 series FPGAs 设计PCB 该选择绑定哪个bank引脚,约束引脚时如何定义引脚电平标准?
  • 算法:选择排序+堆排序
  • UE4/UE5反射系统动态注册机制解析
  • 【开题答辩全过程】以 汽车知名品牌信息管理系统为例,包含答辩的问题和答案
  • rabbitmq 的 TTL
  • Linux内核网络的连接跟踪conntrack简单分析
  • Java Stream流:从入门到精通
  • java常见面试题杂记