建立 DeFi DApp 前端
在本課程的最後一章,我們將建立一個完整的去中心化應用 (DApp) 前端,讓使用者可以透過瀏覽器直接與我們的智慧合約互動。
技術架構
使用者瀏覽器
│
├── MetaMask 錢包(管理私鑰與簽章)
│
▼
React / Next.js DApp
│
├── ethers.js(與區塊鏈溝通)
│
▼
以太坊區塊鏈(智慧合約)
1. 安裝依賴
# 在 Next.js 專案中安裝
npm install ethers @web3-react/core @web3-react/injected-connector
2. 建立錢包連接 Context
// context/Web3Context.jsx
"use client";
import { createContext, useContext, useState, useEffect } from 'react';
import { ethers } from 'ethers';
const Web3Context = createContext();
export function Web3Provider({ children }) {
const [account, setAccount] = useState(null);
const [provider, setProvider] = useState(null);
const [signer, setSigner] = useState(null);
const [chainId, setChainId] = useState(null);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState(null);
const connectWallet = async () => {
if (!window.ethereum) {
setError('請安裝 MetaMask 錢包!');
return;
}
setIsConnecting(true);
setError(null);
try {
const provider = new ethers.BrowserProvider(window.ethereum);
const accounts = await provider.send('eth_requestAccounts', []);
const signer = await provider.getSigner();
const network = await provider.getNetwork();
setProvider(provider);
setSigner(signer);
setAccount(accounts[0]);
setChainId(network.chainId);
// 監聽帳號變更
window.ethereum.on('accountsChanged', (accounts) => {
if (accounts.length === 0) {
setAccount(null);
setSigner(null);
} else {
setAccount(accounts[0]);
}
});
// 監聽鏈變更
window.ethereum.on('chainChanged', (chainId) => {
setChainId(Number(chainId));
});
} catch (err) {
setError(err.message);
} finally {
setIsConnecting(false);
}
};
const disconnectWallet = () => {
setAccount(null);
setSigner(null);
setProvider(null);
};
return (
<Web3Context.Provider value={{
account,
provider,
signer,
chainId,
isConnecting,
error,
connectWallet,
disconnectWallet,
}}>
{children}
</Web3Context.Provider>
);
}
export function useWeb3() {
return useContext(Web3Context);
}
3. 代幣儀表板元件
// components/TokenDashboard.jsx
"use client";
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import { useWeb3 } from '@/context/Web3Context';
// VibeToken ABI(只包含我們需要的函式)
const VIBE_TOKEN_ABI = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)",
"function totalSupply() view returns (uint256)",
"function name() view returns (string)",
"function symbol() view returns (string)",
"event Transfer(address indexed from, address indexed to, uint256 value)"
];
export default function TokenDashboard({ tokenAddress }) {
const { account, signer } = useWeb3();
const [balance, setBalance] = useState('0');
const [totalSupply, setTotalSupply] = useState('0');
const [tokenName, setTokenName] = useState('');
const [tokenSymbol, setTokenSymbol] = useState('');
const [transferTo, setTransferTo] = useState('');
const [transferAmount, setTransferAmount] = useState('');
const [txStatus, setTxStatus] = useState('');
useEffect(() => {
if (account && signer && tokenAddress) {
loadTokenInfo();
}
}, [account, signer, tokenAddress]);
const getTokenContract = () => {
return new ethers.Contract(tokenAddress, VIBE_TOKEN_ABI, signer);
};
const loadTokenInfo = async () => {
try {
const contract = getTokenContract();
const balance = await contract.balanceOf(account);
const totalSupply = await contract.totalSupply();
const name = await contract.name();
const symbol = await contract.symbol();
setBalance(ethers.formatEther(balance));
setTotalSupply(ethers.formatEther(totalSupply));
setTokenName(name);
setTokenSymbol(symbol);
} catch (err) {
console.error('讀取代幣資訊失敗:', err);
}
};
const handleTransfer = async () => {
if (!transferTo || !transferAmount) return;
setTxStatus('交易處理中⋯⋯');
try {
const contract = getTokenContract();
const amount = ethers.parseEther(transferAmount);
const tx = await contract.transfer(transferTo, amount);
setTxStatus(`交易已送出!等待確認⋯⋯ TxHash: ${tx.hash}`);
const receipt = await tx.wait();
setTxStatus(`✅ 轉帳成功!區塊: ${receipt.blockNumber}`);
// 更新餘額
loadTokenInfo();
} catch (err) {
setTxStatus(`❌ 轉帳失敗: ${err.message}`);
}
};
return (
<div className="glass p-8 rounded-3xl border border-primary/20">
<h2 className="text-2xl font-bold mb-6">💰 {tokenName || '代幣'} 儀表板</h2>
<div className="grid grid-cols-2 gap-4 mb-8">
<div className="bg-green-500/10 p-4 rounded-2xl border border-green-500/20">
<p className="text-green-400 text-sm mb-1">你的餘額</p>
<p className="text-3xl font-bold">{Number(balance).toFixed(4)}</p>
<p className="text-green-400/60 text-sm">{tokenSymbol}</p>
</div>
<div className="bg-blue-500/10 p-4 rounded-2xl border border-blue-500/20">
<p className="text-blue-400 text-sm mb-1">總供應量</p>
<p className="text-3xl font-bold">{Number(totalSupply).toLocaleString()}</p>
<p className="text-blue-400/60 text-sm">{tokenSymbol}</p>
</div>
</div>
{/* 轉帳表單 */}
<div className="space-y-4">
<h3 className="font-semibold">轉帳 {tokenSymbol}</h3>
<input
type="text"
placeholder="接收地址 (0x...)"
value={transferTo}
onChange={(e) => setTransferTo(e.target.value)}
className="w-full bg-background border border-border rounded-xl px-4 py-3"
/>
<input
type="number"
placeholder="數量"
value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)}
className="w-full bg-background border border-border rounded-xl px-4 py-3"
/>
<button
onClick={handleTransfer}
className="w-full bg-primary text-primary-foreground py-3 rounded-xl font-bold hover:bg-blue-600"
>
發送 {tokenSymbol}
</button>
{txStatus && (
<p className="text-sm text-foreground/60 mt-2">{txStatus}</p>
)}
</div>
</div>
);
}
4. 連接錢包按鈕
// components/WalletButton.jsx
"use client";
import { useWeb3 } from '@/context/Web3Context';
export default function WalletButton() {
const { account, isConnecting, error, connectWallet, disconnectWallet } = useWeb3();
if (account) {
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-green-500/10 px-4 py-2 rounded-full border border-green-500/20">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-sm font-medium">
{account.slice(0, 6)}...{account.slice(-4)}
</span>
</div>
<button
onClick={disconnectWallet}
className="text-sm text-foreground/60 hover:text-foreground"
>
斷開
</button>
</div>
);
}
return (
<div>
<button
onClick={connectWallet}
disabled={isConnecting}
className="bg-primary text-primary-foreground px-6 py-2 rounded-full font-semibold hover:bg-blue-600 disabled:opacity-50"
>
{isConnecting ? '連線中⋯⋯' : '🦊 連接 MetaMask'}
</button>
{error && (
<p className="text-red-500 text-sm mt-2">{error}</p>
)}
</div>
);
}
5. DApp 頁面
// app/dapp/page.jsx
"use client";
import { Web3Provider, useWeb3 } from '@/context/Web3Context';
import WalletButton from '@/components/WalletButton';
import TokenDashboard from '@/components/TokenDashboard';
function DAppContent() {
const { account, chainId } = useWeb3();
// Sepolia 測試網的 Chain ID
const SEPOLIA_CHAIN_ID = 11155111;
const isCorrectNetwork = chainId === SEPOLIA_CHAIN_ID;
return (
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="flex items-center justify-between mb-12">
<div>
<h1 className="text-4xl font-bold mb-2">🔗 Vibe DApp</h1>
<p className="text-foreground/60">
連接你的錢包,開始探索去中心化世界
</p>
</div>
<WalletButton />
</div>
{!account ? (
<div className="text-center py-20">
<p className="text-2xl mb-4">🦊</p>
<p className="text-xl text-foreground/60">請點擊上方按鈕連接 MetaMask 錢包</p>
</div>
) : !isCorrectNetwork ? (
<div className="glass p-8 rounded-3xl border border-yellow-500/20 text-center">
<p className="text-2xl mb-4">⚠️</p>
<p className="text-xl font-bold mb-2">請切換到 Sepolia 測試網</p>
<p className="text-foreground/60 mb-4">
當前網路 Chain ID: {chainId}
</p>
<button
onClick={async () => {
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0xaa36a7' }], // Sepolia
});
} catch (err) {
console.error('切換網路失敗:', err);
}
}}
className="bg-primary text-primary-foreground px-6 py-3 rounded-xl font-semibold"
>
切換到 Sepolia
</button>
</div>
) : (
<div className="grid gap-8">
<TokenDashboard tokenAddress={process.env.NEXT_PUBLIC_TOKEN_ADDRESS} />
{/* 其他 DeFi 功能 */}
<div className="glass p-8 rounded-3xl border border-primary/20">
<h2 className="text-2xl font-bold mb-4">🚧 更多功能開發中</h2>
<div className="grid grid-cols-2 gap-4">
<div className="bg-purple-500/10 p-6 rounded-2xl border border-purple-500/20 hover:border-purple-500/40 transition-colors cursor-pointer">
<p className="text-3xl mb-2">🏊</p>
<p className="font-semibold">流動性池</p>
<p className="text-sm text-foreground/60">提供流動性賺取手續費</p>
</div>
<div className="bg-blue-500/10 p-6 rounded-2xl border border-blue-500/20 hover:border-blue-500/40 transition-colors cursor-pointer">
<p className="text-3xl mb-2">🏦</p>
<p className="font-semibold">借貸協議</p>
<p className="text-sm text-foreground/60">抵押資產借出代幣</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default function DAppPage() {
return (
<Web3Provider>
<DAppContent />
</Web3Provider>
);
}
使用 Vibe Coding 建立 DApp
🔥 【DApp 前端詠唱範例】
「請幫我建立一個完整的 DeFi DApp 前端:1. 使用 React + Next.js + Tailwind CSS。2. 連接 MetaMask 錢包,顯示餘額。3. 支援切換以太坊網路(主網 / Sepolia)。4. 代幣儀表板:顯示餘額、總供應量、轉帳功能。5. NFT 畫廊:顯示使用者擁有的 NFT 列表與圖片。6. 深色主題,玻璃擬態 (Glassmorphism) 風格。」
本日總結
在本章中,你學到了:
- ✅ Web3 Context:建立全域的錢包連線狀態管理
- ✅ MetaMask 連線:連接錢包、監聽帳號切換
- ✅ 代幣儀表板:讀取代幣資訊、執行轉帳
- ✅ 網路管理:檢查與切換正確的區塊鏈網路
- ✅ 完整的 DApp 頁面:從連線到功能操作的完整流程
恭喜你完成了整個 DeFi 區塊鏈應用 課程!
你現在已經具備了:
- ⛓️ 區塊鏈與智慧合約的核心知識
- 📝 Solidity 程式語言的實戰能力
- 💰 ERC-20 代幣發行(含交易稅與銷毀機制)
- 🎨 ERC-721 NFT 發行(含揭示與版稅)
- 🔄 去中心化交易所(AMM 機制)
- 🏦 去中心化借貸協議(抵押、清算)
- 🌐 DApp 前端開發(ethers.js + MetaMask)
這些技能無論是進入 Web3 產業求職、發行自己的代幣/NFT 專案、或是接案開發 DeFi 協議,都能讓你站在區塊鏈浪潮的最前端!