本文作者Yash 通过一个 WorkLedger 的 DApp 案例,详细介绍了如何从零开始构建一个全栈Web3应用,部署智能合约并通过前端与之交互。
👋 嘿,我是 Yash! 今天,我们将构建一些令人兴奋的东西:一个完整的去中心化应用程序 (DApp),它直接连接到智能合约。我们将在公共场合、链上编写、测试、部署和交互。
我们将这样做:
最棒的是什么? 我们完全跳过了后端,因为我们不需要它,因为所有数据都存储在区块链上。无需信任。透明。永久。
先睹为快
我们正在创建 WorkLedger : 一个链上平台,任何人都可以为你的工作留下推荐信 + 小费。
想象一下:
这意味着:
不需要后端:所有内容(姓名、消息、评分、小费)都直接从链上存储和查询。
在我们编写任何Solidity代码之前,让我们先设置 Foundry,它会像微风一样处理我们的编译、测试和部署。
🎯 目标: 设置 Foundry,以便可以使用 forge
和 cast
构建、测试和部署智能合约。
你只需要**每台机器**执行一次此操作。
curl -L https://foundry.paradigm.xyz | bash
$PATH
非常重要)foundryup
✅ 就是这样。你已经准备好 Forge 和 Cast 了!
让我们创建一个干净的 Foundry 项目:
forge init WorkLedger
cd WorkLedger
Foundry 为你提供了一些默认文件。我们不会使用它们。
你可以删除或清理:
rm -rf src/Counter.sol test/Counter.t.sol
这为我们提供了一个新的画布,可以开始编写 WorkLedger.sol 合约。
forge
— 用于编译、测试和部署智能合约cast
— 用于与 EVM 兼容的区块链交互(如 Sepolia、Polygon 等)检查这两个工具是否可用:
forge --version
cast --version
你应该会看到类似以下内容:
如果你看到版本号,则一切就绪,可以继续进行。 🚀
🎯 目标: 初始化一个新的 Foundry 项目目录,作为智能合约开发的基础。
🧩 步骤 1:导航到你的开发目录
cd ~/dev # 或你存放项目的任何位置
🧩 步骤 2:运行 Foundry 初始化命令
forge init workledger-contract
这将:
workledger-contract
的文件夹workledger-contract/
├── lib/
├── script/
├── src/
├── test/
├── foundry.toml
🧩 步骤 3:移动到新的项目文件夹中
cd workledger-contract
🧩 步骤 4:尝试编译以确认设置
forge build
✅ 你应该会看到类似 Compiler run successful
的内容。如果是,则 Foundry 现在已准备好构建你的合约。
太好了,让我们继续。
🎯 目标: 删除不必要的样板文件,以保持项目干净并仅专注于你的 WorkLedger 合约。
清理 Foundry Scaffold 的步骤:
rm src/Counter.sol
rm test/Counter.t.sol
rm script/Counter.s.sol
如果你计划重用或参考部署脚本格式,则可以暂时保留此文件,稍后再重命名。
🎯 目标: 构建 WorkLedger DApp 的核心——一个智能合约,允许用户提交推荐信、包含 ETH 小费并查看过去的推荐信。这是整个系统的核心。
在你的 src
目录中,创建一个新文件:
touch src/WorkLedger.sol
将以下 Solidity 代码粘贴到其中:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract WorkLedger is ReentrancyGuard {
address public owner;
struct Testimonial {
address from;
string name;
uint256 amount;
string message; // 评论
string workDescription; // 工作内容
uint8 rating; // 5 星评分
uint256 timestamp;
}
Testimonial[] internal testimonials;
mapping(address => Testimonial[]) internal testimonialsBySender;
event TestimonialSubmitted(
address indexed from,
string name;
uint256 amount,
string message,
string workDescription,
uint8 rating,
uint256 timestamp
);
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "只有所有者才能执行此操作");
_;
}
receive() external payable {
revert("使用 leaveTestimonial() 提交评论");
}
function leaveTestimonial(
string calldata message,
string calldata workDescription,
uint8 rating
) external payable nonReentrant {
require(msg.value > 0, "小费必须大于 0");
require(bytes(message).length > 0, "消息不能为空");
require(bytes(workDescription).length > 0, "需要工作描述");
require(rating >= 1 && rating <= 5, "评分必须在 1 到 5 之间");
Testimonial memory t = Testimonial({
from: msg.sender,
amount: msg.value,
message: message,
workDescription: workDescription,
rating: rating,
timestamp: block.timestamp
});
testimonials.push(t);
testimonialsBySender[msg.sender].push(t);
emit TestimonialSubmitted(
msg.sender,
msg.value,
message,
workDescription,
rating,
block.timestamp
);
}
function getAllTestimonials() external view returns (Testimonial[] memory) {
return testimonials;
}
function getMyTestimonials(
address user
) external view returns (Testimonial[] memory) {
return testimonialsBySender[user];
}
function withdrawTips() external onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "没有小费可提取");
(bool success, ) = payable(owner).call{value: balance}("");
require(success, "转账失败");
}
function getContractBalance() external view returns (uint256) {
return address(this).balance;
}
}
WorkLedger
合约中发生了什么?好的,这就是真实的:我们刚刚编写了整个 DApp 的核心——WorkLedger
合约。它将永远存在于区块链上,并存储为你工作收到的每条推荐信。
没有删除按钮。没有中心机构。只有附加了 ETH 小费的纯链上赞扬。
Testimonial
结构体 + 存储我们定义了一个 Testimonial
结构体,其中包含:
from
:谁发送了推荐信name
:以便你知道是谁发的message
:评论文本workDescription
:你为他们做了什么rating
:1-5 星🌟amount
:他们给的小费金额timestamp
:发生的时间所有推荐信都进入一个名为 testimonials
的数组。我们还维护一个按发送者划分的映射:testimonialsBySender
。
leaveTestimonial()
函数这就是奇迹发生的地方。
用户调用此函数以:
我们验证:
如果一切都检查完毕,我们将:
这是一个透明的、永久的、有价值的工作认可。
getAllTestimonials()
→ 获取所有推荐信getMyTestimonials(address)
→ 获取特定用户的推荐信可以将其视为LinkedIn 背书,但不可阻挡。
withdrawTips()
所有小费都存在于合约中。此函数允许所有者提取累积的 ETH。
当然,受 onlyOwner
保护。
nonReentrant
修饰符,用于防止重入攻击receive()
回退会恢复直接 ETH 转账——这里没有后门你的合约中可能存在此导入错误:
要解决此问题,请运行以下命令以安装 openzepplin 包。
forge install OpenZeppelin/openzeppelin-contracts
它会将 openZepplin 合约安装在你的工作目录中。另请确保在你的 .gitignore 文件中添加“lib/”。否则,它会将所有 oz 合约推送到 github。
继续。让我们使用 foundry 测试我们的合约。
🎯 目标: 确保 WorkLedger 合约完全按照我们期望的方式工作。
我们将使用 Foundry 的测试框架尽早发现错误、处理极端情况并验证它如何处理小费、评论和权限。
在你的 test/
文件夹中,运行:
touch test/WorkLedger.t.sol
将此代码粘贴到其中:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/WorkLedger.sol";
contract WorkLedgerTest is Test {
WorkLedger public workLedger;
address public owner;
address tipper;
receive() external payable {}
function setUp() public {
workLedger = new WorkLedger();
owner = address(this);
tipper = address(0x1);
vm.deal(tipper, 5 ether); // fund the tipper
}
function testLeaveValidTestimonial() public {
vm.prank(tipper);
workLedger.leaveTestimonial{value: 1 ether}(
"Great work!",
"Landing page design",
"John Doe",
5
);
WorkLedger.Testimonial[] memory all = workLedger.getAllTestimonials();
assertEq(all.length, 1);
assertEq(all[0].from, tipper);
assertEq(all[0].amount, 1 ether);
assertEq(all[0].rating, 5);
assertEq(all[0].message, "Great work!");
assertEq(all[0].name, "John Doe");
}
function testRevertOnEmptyMessage() public {
vm.prank(tipper);
vm.expectRevert("Message cannot be empty");
workLedger.leaveTestimonial{value: 1 ether}(
"",
"Landing page design",
"John Doe",
4
);
}
function testRevertOnEmptyWorkDescription() public {
vm.prank(tipper);
vm.expectRevert("Work description required");
workLedger.leaveTestimonial{value: 1 ether}("Superb", "", "", 4);
}
function testRevertOnInvalidRating() public {
vm.prank(tipper);
vm.expectRevert("Rating must be between 1 and 5");
workLedger.leaveTestimonial{value: 1 ether}(
"Clean code",
"Smart contract",
"Jane Smith",
0
);
}
function testRevertOnZeroETH() public {
vm.prank(tipper);
vm.expectRevert("Tip must be greater than 0");
workLedger.leaveTestimonial{value: 0}(
"Awesome job",
"Figma UI",
"Jane",
5
);
}
function testEmptyName() public {
vm.prank(tipper);
vm.expectRevert("Name cannot be empty");
workLedger.leaveTestimonial{value: 1 ether}(
"Great work",
"Frontend development",
"",
4
);
}
function testWithdrawTips() public {
vm.prank(tipper);
workLedger.leaveTestimonial{value: 2 ether}(
"Excellent delivery",
"Backend service",
"John",
5
);
uint256 balanceBefore = workLedger.getContractBalance();
assertEq(balanceBefore, 2 ether);
uint256 ownerBalBefore = owner.balance;
workLedger.withdrawTips();
assertEq(workLedger.getContractBalance(), 0);
assertGt(owner.balance, ownerBalBefore);
}
function testWithdrawFailsIfNotOwner() public {
vm.prank(tipper);
vm.expectRevert("Only owner can perform this action");
workLedger.withdrawTips();
}
}
好了,合约完成了。看起来很整洁。
但我们不提供感觉,我们提供经过实战考验的代码。
因此,让我们使用 Foundry 的 forge-std/Test.sol
_压力测试_一下。
setUp()
:为每次测试做准备function setUp() public {
workLedger = new WorkLedger();
owner = address(this);
tipper = address(0x1);
vm.deal(tipper, 5 ether); // fund the tipper
}
每次测试都以干净的状态开始:
testLeaveValidTestimonial
workLedger.leaveTestimonial{value: 1 ether}(...)
此测试确认:
💡 这是“一切正常”的情况。
每个测试都确保我们不会让任何事情溜走。
"Message cannot be empty"
"Work description required"
"Rating must be between 1 and 5"
"Tip must be greater than 0"
"Name cannot be empty"
📛 这些测试有助于防止垃圾数据永久进入区块链。
testWithdrawTips
:ETH 提现检查withdrawTips()
🧾 这是发薪日,但前提是你是部署合约的人。
testWithdrawFailsIfNotOwner
:访问控制检查"Only owner can perform this action"
因为,说实话,不是你的合约,就不是你的币。
你的测试涵盖:
🛡️ 你不仅仅是在构建一个 DApp,你还在锁定它。
所以,是的,这些测试涵盖:
如果你使用此命令运行所有这些测试,并且它们通过,那么你就可以确定了。
forge test -vv
你应该会看到所有通过的测试和详细的日志。
接下来,我们将将其部署到 Sepolia,并使其在测试网上生效。准备好进入部署了吗?
🎯 目标: 使用 Foundry 将 WorkLedger
合约部署到 Sepolia 测试网,以便稍后我们可以通过前端与之交互。
.env
首先,在你的根目录中创建一个 .env
文件:
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_KEY
PRIVATE_KEY=your_private_key_without_0x
如何获取此env变量?
1. 获取 SEPOLIA_RPC_URL
✅ 如果你使用的是 MetaMask
Sepolia
或其他测试网。⚠️ 不要公开分享此密钥。 即使它是一个测试网帐户,也应将其视为敏感信息。
将其保存在 .env
中(用于 Foundry)
https://eth-sepolia.g.alchemy.com/v2/your-alchemy-key
完成,现在将该 url 设置为你的应用程序 .env
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_ALCHEMY_KEY
最新的 .gitignore 文件
## Compiler files
cache/
out/
lib/
node_modules/
## Ignores development broadcast logs
!/broadcast
/broadcast/*/31337/
/broadcast/**/dry-run/
## Docs
docs/
## Dotenv file
.env
foundry.toml
以使用 Sepolia在你的 foundry.toml
中,添加:
[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
这将 Sepolia RPC 链接到 Foundry 的网络别名系统。
在 script/DeployWorkLedger.s.sol
中:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {WorkLedger} from "../src/WorkLedger.sol";
contract DeployWorkLedger is Script {
WorkLedger public workLedger;
function setUp() public {}
function run() public {
vm.startBroadcast();
workLedger = new WorkLedger();
vm.stopBroadcast();
console.log("Workledger deployed to:", address(workLedger));
console.log("Owner address:", workLedger.owner());
}
}
在部署之前,请确保你有一些 testnet sepolia,如果没有,请使用此 faucet 将一些 testnet tokens 获取到你的钱包中:Ethereum Sepolia Faucet
现在让我们使用脚本部署它,但在那之前,我们需要将我们的 env 密钥导出到我们的终端。将你的 env 添加到你的终端,以便它可以在下一个命令中使用这些值。
例如:添加你的密钥并按 Enter。
PRIVATE_KEY=<YOUR_PRIVATE_KEY>
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/your-alchemy-key
现在你的终端将在需要时快速访问此值。
接下来,运行此命令以模拟部署:
forge script script/DeployWorkLedger.s.sol:DeployWorkLedger \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY \
--rpc-url
连接到特定网络(主网/测试网)
--private-key
如果你要签名和广播交易,则需要
正如你所看到的,这对于以下方面非常有用:
--broadcast
将交易发送到真实网络(例如 Sepolia)
没有 --broadcast
模拟使用当前网络状态的脚本(试运行)
这将:
Foundry 将向你显示合约地址。保存此地址,你将在前端中使用它。
在此处查看我的合约:Address 0x788f7F2367122e77eeFAE829f65B21701CdF4B74 | Etherscan
🎯 目标: 在 sepolia.etherscan.io 上验证你的合约,以便人们(和你的前端)可以读取合约代码、调用视图函数并信任其合法性。
前往 https://etherscan.io/myapikey,登录并获取你的与 Sepolia 兼容的 API 密钥。
然后将其添加到你的 .env
中:
ETHERSCAN_API_KEY=your_key_here
foundry.toml
更新你的 foundry.toml
:
[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }
这告诉 Foundry 如何在 Sepolia 上验证合约。
如果你已经使用 --verify
进行了部署,那么你就完成了。
但如果不是,以下是如何使用相同脚本手动验证:
forge verify-contract CONTRACT_ADDRESS src/WorkLedger.sol:WorkLedger --
chain-id 11155111
替换:
CONTRACT_ADDRESS
换成你部署的 Sepolia 地址你将收到一条消息,例如:
现在,当你在 Etherscan 上访问 Sepolia 合约地址时,你将看到:
WorkLedger | Address 0xfaba9fcaaa6b2c7f3716b4b09f3a26b666eb8842 | Etherscan
如果你已经走到这一步,请花点时间祝贺自己。你已经正式编写、测试、保护、部署和验证了智能合约,该合约在区块链上存储不可变的评论和 ETH 小费。
💯 智能合约之旅现已完成。
想查看完整的代码库或克隆它吗?
是时候转换思路了,让我们构建一个前端,以便在浏览器中与这个野兽交互。我们即将把 WorkLedger 从逻辑 → 实时体验。
让我们使其可视化。让我们使其具有交互性。
🚀 让我们构建 UI。
从智能合约到用户界面
🎯 目标: 启动一个新的 Next.js 应用程序,我们将在其中构建 UI 以与我们在 Sepolia 测试网上部署的 WorkLedger
智能合约进行交互。
选择你的工作区目录并在你的终端中运行以下命令:
npx create-next-app@latest workledger-frontend --typescript
出现提示时:
src/
目录布局💡 我们跳过了默认的 Tailwind 设置,以便稍后可以使用更新、更模块化的 Shadcn 设置。更清洁。可扩展。更易于维护。
然后移入项目:
cd workledger-frontend
删除样板垃圾:
rm -rf src/app/favicon.ico src/app/page.tsx
删除公共文件夹中的所有内容。你将使用你自己的布局和组件替换它们。
可选但始终是一个好主意:
git add .
git commit -m "Init: fresh Next.js setup for WorkLedger frontend"
现在你有一个干净的、支持 TypeScript 的 Next.js 应用程序,可以集成你的链上合约。
🎯 目标: 添加 TailwindCSS 以实现实用程序优先的样式设置,并集成 Shadcn UI 组件以加快干净、可访问的 UI 开发速度。
运行官方 Tailwind 设置:
npm install tailwindcss @tailwindcss/postcss postcss
npx tailwindcss-cli init -p
这将创建:
tailwind.config.js
postcss.config.js
现在,配置 Tailwind 以与你的应用程序一起使用。
更新你的 postcss.config.js
文件
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
在 tailwind.config.js
中,更新 content 路径:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
然后,在 ./app/globals.css
中,添加 Tailwind 导入:
@import 'tailwindcss';
现在要验证是否已成功安装 tailwind,请在你的 app 目录中创建 page.tsx 并运行下一个服务器“npm run dev”
export default function Home() {
return <h1 className='text-3xl font-bold underline'>Hello world!</h1>;
}
因此,你的用户界面应显示此内容,而不会出现错误:
你也可以通过此提交进行检查并比较你的代码:
Feat: Integrate Tailwind CSS for styling · Yash-verma18/workledger-frontend@837f07e
运行设置:
npx shadcn@latest init
按照提示操作:
如果出现此警告,请按 Enter。(使用 force)
以与链上的 WorkLedger 合约交互。
yarn add wagmi viem @rainbow-me/rainbowkit
或者如果使用 npm:
npm install wagmi viem @rainbow-me/rainbowkit
这会给你:
在 src/app/layout.tsx
(或你的根布局所在的任何位置)中,设置:
// src/app/layout.tsx
'use client';
import './globals.css';
import { ReactNode, useState } from 'react';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from '../../wagmi.config'; // 如果需要,调整路径
import '@rainbow-me/rainbowkit/styles.css';
export default function RootLayout({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<html lang='en'>
<body>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>{children}</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
</body>
</html>
);
}
所以我们正在用 RainbowKit 连接钱包按钮所需的包装器来包装我们的应用程序。
wagmi.config.ts
。// src/wagmi.config.ts
import { getDefaultConfig } from '@rainbow-me/rainbowkit';
import { sepolia } from 'wagmi/chains';
export const config = getDefaultConfig({
appName: 'WorkLedger',
projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, // 来自 WalletConnect
chains: [sepolia],
ssr: true,
});
现在获取 WalletConnect 项目 ID。
📌 你需要从 https://cloud.walletconnect.com/ 获取一个 WalletConnect 项目 ID
获取你的项目 ID,在你的前端根目录中创建 .env,并像这样添加(粘贴你自己的 ID)。
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=YOUR_PROJECT_ID
在组件文件夹中的任何位置,创建一个名为:rainbowKitComponent 的子文件夹。在此文件夹中创建 WalletConnect.tsx 文件。
'use client';
import { ConnectButton } from '@rainbow-me/rainbowkit';
export default function WalletConnect() {
return <ConnectButton />;
}
现在在你的 src/app/page.tsx 中导入此组件
import WalletConnect from '@/components/rainbowKit/WalletConnect';
export default function Home() {
return (
<div className='min-h-screen flex items-center justify-center bg-gray-100'>
<WalletConnect />
</div>
);
}
现在当你运行你的应用程序时,你应该会看到一个 Connect Wallet 按钮,以及开箱即支持的所有主要钱包。
这将是它的样子。
然后你可以将你的钱包与应用程序连接,点击 Metamask 或选择你喜欢的任何一个。
连接后,你将看到你的余额和你的地址。
用户现在可以:
你已准备好在此连接的基础上开始构建功能。开始吧!
你可以在这里比较你的代码差异:Feat: Integrate RainbowKit for wallet connection · Yash-verma18/workledger-frontend@d1f1444
我们将在 /dashboard
创建新路由。
在 Next.js(App Router)中,路由被处理为 app/
目录中的文件夹 。
所以让我们执行以下操作:
app/
文件夹中,创建一个名为 dashboard
的新文件夹page.tsx
的新文件这样你就定义了 /dashboard
路由。
// src/app/dashboard/page.tsx
import React from 'react';
export const metadata = {
title: 'Dashboard | MyApp',
description: '你的个人仪表板页面',
};
const Dashboard = () => {
return (
<div>
<h1>Our Dashboard</h1>
</div>
);
};
export default Dashboard;
现在,如果你访问 /dashboard
,你应该会看到我们的 <h1>
标签被渲染。
我们接下来想要的是简单的:
一旦用户连接了他们的 MetaMask 钱包,他们应该被自动重定向到此仪表板页面。
为了实现这一点,让我们更新 page.tsx
文件,即我们添加 Connect Wallet 按钮的文件。
// src/app/page.tsx
import WalletConnect from '@/components/rainbowKit/WalletConnect';
import { useAccount } from 'wagmi';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function Home() {
const { isConnected } = useAccount();
const router = useRouter();
useEffect(() => {
if (isConnected) {
router.push('/dashboard');
}
}, [isConnected, router]);
return (
<div className='min-h-screen flex items-center justify-center bg-gray-100'>
<WalletConnect />
</div>
);
}
所以这里发生了什么?
我们正在使用 Wagmi 中的 useAccount
hook 来检查用户是否已连接到他们的钱包。除此之外,我们正在使用 React 的 useEffect
和 Next.js 的 useRouter
。
这是流程:
useEffect
监视钱包连接状态。useRouter
触发到 /dashboard
的重定向。🚀 继续测试它。通过 MetaMask 连接你的钱包,你应该立即被重定向到 /dashboard
。
目前,我们有这个 Connect Wallet 按钮的计划外观,让我们让它更酷。
首先安装这个库
npm i simplex-noise
现在在组件目录中创建一个名为 bg 的文件夹,在其中创建一个文件:wavy-background.tsx
将以下代码复制到文件中
// components/bg/wavy-background.tsx
'use client';
import { cn } from '@/lib/utils';
import React, { useEffect, useRef, useState } from 'react';
import { createNoise3D } from 'simplex-noise';
export const WavyBackground = ({
children,
className,
containerClassName,
colors,
waveWidth,
backgroundFill,
blur = 10,
speed = 'fast',
waveOpacity = 0.5,
...props
}: {
children?: any;
className?: string;
containerClassName?: string;
colors?: string[];
waveWidth?: number;
backgroundFill?: string;
blur?: number;
speed?: 'slow' | 'fast';
waveOpacity?: number;
[key: string]: any;
}) => {
const noise = createNoise3D();
let w: number,
h: number,
nt: number,
i: number,
x: number,
ctx: any,
canvas: any;
const canvasRef = useRef<HTMLCanvasElement>(null);
const getSpeed = () => {
switch (speed) {
case 'slow':
return 0.001;
case 'fast':
return 0.002;
default:
return 0.001;
}
};
const init = () => {
canvas = canvasRef.current;
ctx = canvas.getContext('2d');
w = ctx.canvas.width = window.innerWidth;
h = ctx.canvas.height = document.body.scrollHeight;
ctx.filter = `blur(${blur}px)`;
nt = 0;
window.onresize = function () {
w = ctx.canvas.width = window.innerWidth;
h = ctx.canvas.height = document.body.scrollHeight;
ctx.filter = `blur(${blur}px)`;
};
render();
};
const waveColors = colors ?? [\
'#38bdf8',\
'#818cf8',\
'#c084fc',\
'#e879f9',\
'#22d3ee',\
];
const drawWave = (n: number) => {
nt += getSpeed();
for (i = 0; i < n; i++) {
ctx.beginPath();
ctx.lineWidth = waveWidth || 50;
ctx.strokeStyle = waveColors[i % waveColors.length];
for (x = 0; x < w; x += 5) {
var y = noise(x / 800, 0.3 * i, nt) * 100;
ctx.lineTo(x, y + h * 0.1 + i * 40);
}
ctx.stroke();
ctx.closePath();
}
};
let animationId: number;
const render = () => {
ctx.fillStyle = backgroundFill || 'black';
ctx.globalAlpha = waveOpacity || 0.5;
ctx.fillRect(0, 0, w, h);
drawWave(8);
animationId = requestAnimationFrame(render);
};
useEffect(() => {
init();
return () => {
cancelAnimationFrame(animationId);
};
}, []);
const [isSafari, setIsSafari] = useState(false);
useEffect(() => {
setIsSafari(
typeof window !== 'undefined' &&
navigator.userAgent.includes('Safari') &&
!navigator.userAgent.includes('Chrome')
);
}, []);
return (
<div className={cn(' ', containerClassName)}>
<canvas
className='fixed top-0 left-0 w-full h-full z-0 absolute inset-0 z-0'
ref={canvasRef}
id='canvas'
style={{
...(isSafari ? { filter: `blur(${blur}px)` } : {}),
}}
></canvas>
<div className={cn('relative z-10', className)} {...props}>
{children}
</div>
</div>
);
};
现在让我们在布局中包装此组件,以便我们的应用程序的每个子组件都可以拥有此背景。
你更新后的 layout.tsx 将如下所示:
// src/app/layout.tsx
'use client';
import './globals.css';
import { ReactNode, useState } from 'react';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from '../../wagmi.config'; // 如果需要,调整路径
import '@rainbow-me/rainbowkit/styles.css';
import { WavyBackground } from '@/components/bg/wavy-background';
export default function RootLayout({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<html lang='en'>
<body>
<WavyBackground>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>{children}</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
</WavyBackground>
</body>
</html>
);
}
此外,为了进行最后一次更改,我们需要删除应用于根 page.tsx (src/app/page.tsx) 中的背景类。
因此,你更新后的文件将如下所示:
'use client';
import { useAccount } from 'wagmi';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import WalletConnect from '@/components/rainbowKit/WalletConnect';
export default function Home() {
const { isConnected } = useAccount();
const router = useRouter();
useEffect(() => {
if (isConnected) {
router.push('/dashboard');
}
}, [isConnected, router]);
return (
<div className='min-h-screen flex items-center justify-center '>
<WalletConnect />
</div>
);
}
查看此提交以获取更多参考:Feat: Implement wavy background effect · Yash-verma18/workledger-frontend@235ce74
首先将这两个 svg 添加到我们的 public 目录中。你可以从 repo 本身获取此 svg,下载此:
workledger-frontend/public at master · Yash-verma18/workledger-frontend
现在从我们的 globals.css 文件中删除任何冲突文件,以便我们的视觉元素正常工作。所以删除这个:
现在更新我们的 page.tsx 以使用我们添加的 svg。
// src/app/page.tsx
'use client';
import { useAccount } from 'wagmi';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import WalletConnect from '@/components/rainbowKit/WalletConnect';
import Image from 'next/image';
export default function Home() {
const { isConnected } = useAccount();
const router = useRouter();
useEffect(() => {
if (isConnected) {
router.push('/dashboard');
}
}, [isConnected, router]);
return (
<div className='flex flex-col items-center justify-center w-full gap-8 mt-20'>
<Image
src='/work.svg'
alt='Work'
width={1920}
height={300}
className='w-full max-w-[80%] object-contain mx-auto '
priority
/>
<WalletConnect />
<Image
src='/ledger.svg'
alt='Ledger'
width={1920}
height={300}
className='w-full max-w-[80%] object-contain '
/>
</div>
);
}
所以现在你的页面必须看起来像这样:
查看提交以获取更多详细信息:Feat: Enhance homepage with visual elements · Yash-verma18/workledger-frontend@ff52064
非常棒!!现在它看起来好多了。
首先,在仪表板中,我们需要一个导航栏。
让我们首先添加导航栏,首先我们需要一些图标,所以安装这个:
npm i @radix-ui/react-icons
现在,在组件目录中创建一个组件文件夹 navbar。
添加这两个文件:
breadcrumb.tsx // 此文件是我们导航栏的核心组件。
// src/components/navbar/breadcrumb.tsx
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label='breadcrumb' {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-neutral-300 dark:text-neutral-200 sm:gap-2.5',
className
)}
{...props}
/>
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn(
'transition-colors hover:text-white/90 dark:hover:text-white/95',
className
)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role='link'
aria-disabled='true'
aria-current='page'
className={cn('text-foreground', className)}
{...props}
/>
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li role='presentation' aria-hidden='true' className={className} {...props}>
{children ?? <ChevronRightIcon strokeWidth={2} />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role='presentation'
aria-hidden='true'
className={cn('flex size-5 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon strokeWidth={2} />
</span>
);
BreadcrumbEllipsis.displayName = ' BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
};
现在创建这个文件:
Navbar.tsx
在此文件中粘贴此:
'use client';
import { useDisconnect } from 'wagmi';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from './breadcrumb';
import { Home } from 'lucide-react';
function Navbar() {
const { disconnect } = useDisconnect();
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href='/' onClick={() => disconnect()}>
<Home strokeWidth={2} aria-hidden='true' />
<span className='sr-only'>Disconnect</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator> / </BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink style={{ cursor: 'pointer' }}>
Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator> / </BreadcrumbSeparator>
<BreadcrumbItem style={{ cursor: 'pointer' }}>
<BreadcrumbLink>Leave Testimonial</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
export { Navbar };
太好了,现在我们准备好组件了,让我们在我们的仪表板中使用它,所以在 `src/app/dashboard/page.tsx` 中
import { Navbar } from '@/components/navbar/Navbar';
import React from 'react';
export const metadata = {
title: 'Dashboard | MyApp',
description: '你的个人仪表板页面',
};
const Dashboard = () => {
return (
<div>
<Navbar />
</div>
);
};
export default Dashboard;
这应该看起来像这样:
查看提交:Feat: Implement dashboard navbar with breadcrumbs · Yash-verma18/workledger-frontend@0e1238d
好的,所以基本上,我们希望任何人都填写此表单以向用户提交任何类型的推荐,所以正如我们所知我们的合约如何工作,我们必须获得以下内容才能提交推荐。
{
address from;
string name;
uint256 amount;
string message; // 评论
string workDescription; // 是什么工作
uint8 rating; // 满分 5 星的评分
uint256 timestamp;
}
所以让我们首先关注 ui 表单,它将具有所有必需的输入字段。所以首先从 shadcn 安装一些基本组件。
npx shadcn@latest add input
npx shadcn@latest add textarea
npx shadcn@latest add button
npx shadcn@latest add label
现在让我们创建我们的组件:TestimonialForm.tsx
// src\components\forms\TestimonialForm.tsx
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
export default function TestimonialForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [name, setName] = useState('');
const [work, setWork] = useState('');
const [message, setMessage] = useState('');
const [rating, setRating] = useState(5);
const [tip, setTip] = useState('0.01');
const handleSubmit = async () => {
if (!work || !message) return alert('Fill all fields');
console.log({
name,
work,
message,
rating,
tip,
});
setIsSubmitting(true);
try {
setWork('');
setName('');
setMessage('');
setRating(5);
setTip('0.01');
} catch (err) {
console.error('❌ Error submitting testimonial:', err);
alert('Transaction failed.');
}
setIsSubmitting(false);
};
return (
<div className='min-h-screen dark:bg-zinc-950 flex items-center justify-center px-4 py-4 '>
<div className='w-full max-w-xl bg-white dark:bg-zinc-900 rounded-xl shadow-xl p-8 space-y-6'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-white'>
留下推荐 💬
</h2>
<div className='space-y-2'>
<Label>你的名字</Label>
<Input
placeholder='Chandler Bing'
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label>工作描述</Label>
<Input
placeholder='Built a cool dApp...'
value={work}
onChange={(e) => setWork(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label>你的消息</Label>
<Textarea
placeholder='Yash was super fast and delivered amazing work!'
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label>评分 (1–5)</Label>
<Input
type='number'
min='1'
max='5'
value={rating}
onChange={(e) => setRating(parseInt(e.target.value))}
/>
</div>
<div className='space-y-2'>
<Label>ETH 小费</Label>
<Input
type='number'
step='0.001'
value={tip}
onChange={(e) => setTip(e.target.value)}
/>
</div>
<Button
onClick={handleSubmit}
className='w-full'
disabled={isSubmitting}
>
💸 {isSubmitting ? '正在提交...' : '发送小费 + 留下评论'}
</Button>
</div>
</div>
);
}
现在,让我们从我们的主仪表板页面调用此组件。
// src\app\dashboard\page.tsx
'use client';
import TestimonialForm from '@/components/forms/TestimonialForm';
import { Navbar } from '@/components/navbar/Navbar';
import React, { useState } from 'react';
const Dashboard = () => {
return (
<div>
<Navbar />
<TestimonialForm />
</div>
);
};
export default Dashboard;
这应该看起来像这样:
将代码与提交进行比较:Feat: Implement testimonial form in dashboard · Yash-verma18/workledger-frontend@8ef62e9
现在我们的表单已准备就绪,是时候将其连接到区块链或简而言之,与我们在 Sepolia 上部署的智能合约进行交互。
为此,我们需要一件关键的事情:我们合约的 ABI。
用简单的语言来说:
ABI (Application Binary Interface) 就像一个 蓝图 或 接口,它告诉你的应用程序:
没有 ABI,你的前端将不知道如何与合约“对话”。
这是我们已部署合约的 Sepolia 地址:
0xFaBa9FcAAa6B2C7f3716b4B09F3A26b666eb8842
现在前往 Etherscan Sepolia。
我们将在我们的前端代码中使用此 ABI 与合约函数进行交互,例如 leaveTestimonial()
和 getAllTestimonials()
。
在 lib/
目录中创建一个名为 WorkLedgerABI.ts
的文件。
在其中,定义一个常量并粘贴你之前复制的 ABI。它应该看起来像这样:
export const WorkLedgerABI = [\
{ inputs: [], stateMutability: 'nonpayable', type: 'constructor' },\
{ inputs: [], name: 'ReentrancyGuardReentrantCall', type: 'error' },\
{\
anonymous: false,\
inputs: [\
{ indexed: true, internalType: 'address', name: 'from', type: 'address' },\
{ indexed: false, internalType: 'string', name: 'name', type: 'string' },\
{\
indexed: false,\
internalType: 'uint256',\
name: 'amount',\
type: 'uint256',\
},\
{\
indexed: false,\
internalType: 'string',\
name: 'message',\
type: 'string',\
},\
{\
indexed: false,\
internalType: 'string',\
name: 'workDescription',\
type: 'string',\
},\
{ indexed: false, internalType: 'uint8', name: 'rating', type: 'uint8' },\
{\
indexed: false,\
internalType: 'uint256',\
name: 'timestamp',\
type: 'uint256',\
},\
],\
name: 'TestimonialSubmitted',\
type: 'event',\
},\
{\
inputs: [],\
name: 'getAllTestimonials',\
outputs: [\
{\
components: [\
{ internalType: 'address', name: 'from', type: 'address' },\
{ internalType: 'string', name: 'name', type: 'string' },\
{ internalType: 'uint256', name: 'amount', type: 'uint256' },\
{ internalType: 'string', name: 'message', type: 'string' },\
{ internalType: 'string', name: 'workDescription', type: 'string' },\
{ internalType: 'uint8', name: 'rating', type: 'uint8' },\
{ internalType: 'uint256', name: 'timestamp', type: 'uint256' },\
],\
internalType: 'struct WorkLedger.Testimonial[]',\
name: '',\
type: 'tuple[]',\
},\
],\
stateMutability: 'view',\
type: 'function',\
},\
{\
inputs: [],\
name: 'getContractBalance',\
outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],\
stateMutability: 'view',\
type: 'function',\
},\
{\
inputs: [{ internalType: 'address', name: 'user', type: 'address' }],\
name: 'getMyTestimonials',\
outputs: [\
{\
components: [\
{ internalType: 'address', name: 'from', type: 'address' },\
{ internalType: 'string', name: 'name', type: 'string' },\
{ internalType: 'uint256', name: 'amount', type: 'uint256' },\
{ internalType: 'string', name: 'message', type: 'string' },\
{ internalType: 'string', name: 'workDescription', type: 'string' },```markdown
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useAccount, useWriteContract } from 'wagmi';
import { parseEther } from 'viem';
import { WorkLedgerABI } from '@/lib/WorkLedgerABI';
import { WORKLEDGER_ADDRESS } from '@/lib/constants';
import { TestimonialType } from '@/lib/type';
interface TestimonialFormProps {
onSubmitted: () => void;
setTestimonials: React.Dispatch<React.SetStateAction<TestimonialType[]>>;
}
export default function TestimonialForm({
onSubmitted,
setTestimonials,
}: TestimonialFormProps) {
const { isConnected, address } = useAccount();
const { writeContractAsync } = useWriteContract();
const [isSubmitting, setIsSubmitting] = useState(false);
const [name, setName] = useState('');
const [work, setWork] = useState('');
const [message, setMessage] = useState('');
const [rating, setRating] = useState(5);
const [tip, setTip] = useState('0.01');
const handleSubmit = async () => {
if (!isConnected || !address) return alert('首先连接钱包');
if (!work || !message) return alert('填写所有字段');
setIsSubmitting(true);
try {
const txHash = await writeContractAsync({
address: WORKLEDGER_ADDRESS,
abi: WorkLedgerABI,
functionName: 'leaveTestimonial',
args: [message, work, name, rating],
value: parseEther(tip),
});
console.log('✅ Tx submitted:', txHash);
setTestimonials((prev) => [\
{\
from: address,\
name,\
message,\
workDescription: work,\
rating,\
tip: `${tip} ETH`,\
timestamp: 'just now',\
},\
...prev,\
]);
setWork('');
setName('');
setMessage('');
setRating(5);
setTip('0.01');
onSubmitted?.();
} catch (err) {
console.error('❌ Error submitting testimonial:', err);
alert('交易失败。');
}
setIsSubmitting(false);
};
return (
<div className='min-h-screen dark:bg-zinc-950 flex items-center justify-center px-4 py-4 '>
<div className='w-full max-w-xl bg-white dark:bg-zinc-900 rounded-xl shadow-xl p-8 space-y-6'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-white'>
发表评价 💬
</h2>
<div className='space-y-2'>
<Label>你的名字</Label>
<Input
placeholder='Chandler Bing'
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label>工作描述</Label>
<Input
placeholder='Built a cool dApp...'
value={work}
onChange={(e) => setWork(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label>你的留言</Label>
<Textarea
placeholder='Yash was super fast and delivered amazing work!'
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label>评分 (1–5)</Label>
<Input
type='number'
min='1'
max='5'
value={rating}
onChange={(e) => setRating(parseInt(e.target.value))}
/>
</div>
<div className='space-y-2'>
<Label>小费 (ETH)</Label>
<Input
type='number'
step='0.001'
value={tip}
onChange={(e) => setTip(e.target.value)}
/>
</div>
<Button
onClick={handleSubmit}
className='w-full'
disabled={isSubmitting}
>
💸 {isSubmitting ? '正在提交...' : '发送小费 + 发表评价'}
</Button>
</div>
</div>
);
}
'use client';
import TestimonialForm from '@/components/forms/TestimonialForm';
import { Navbar } from '@/components/navbar/Navbar';
import { TestimonialType } from '@/lib/type';
import React, { useState } from 'react';
const Dashboard = () => {
const [testimonials, setTestimonials] = useState<TestimonialType[]>([]);
const [showTestimonialForm, setOpen] = useState(false);
console.log('testimonials', testimonials);
return (
<div>
<Navbar />
<TestimonialForm
onSubmitted={() => setOpen(false)}
setTestimonials={setTestimonials}
/>
</div>
);
};
export default Dashboard;
它现在应该看起来像这样:
如果你已经做到了这一步,认真地,给自己一个鼓励。🙌
看到你自己的智能合约在运行,并真正在链上与之交互,这是一种超现实的感觉。
做你自己最好的朋友,庆祝这个里程碑。你做到了。 👏👏
好了,继续前进,乐趣才刚刚开始。让我们保持这种势头。🚀
我们现在要做的是:
我们将更新我们的 app/dashboard/page.tsx
以执行以下操作:
usePublicClient
readContract
来调用智能合约TestimonialType[]
数组formatEther
格式化小费金额toLocaleString()
将 timestamp
转换为人类可读的格式完成此操作后,你将在仪表板中看到由你的智能合约提供支持的真实链上数据。💪
'use client';
import TestimonialForm from '@/components/forms/TestimonialForm';
import { Navbar } from '@/components/navbar/Navbar';
import { TestimonialType } from '@/lib/type';
import React, { useEffect, useState } from 'react';
import { readContract } from 'viem/actions';
import { usePublicClient } from 'wagmi';
import { WORKLEDGER_ADDRESS } from '@/lib/constants';
import { WorkLedgerABI } from '@/lib/WorkLedgerABI';
import { formatEther } from 'viem';
const Dashboard = () => {
const [testimonials, setTestimonials] = useState<TestimonialType[]>([]);
const [showTestimonialForm, setOpen] = useState(false);
const publicClient = usePublicClient();
useEffect(() => {
if (!publicClient) return;
const fetchTestimonials = async () => {
try {
const data = await readContract(publicClient, {
address: WORKLEDGER_ADDRESS,
abi: WorkLedgerABI,
functionName: 'getAllTestimonials',
});
console.log('data', data);
const mapped = (data as any[]).map((t: any) => ({
from: t.from,
name: t.name,
message: t.message,
workDescription: t.workDescription,
rating: Number(t.rating),
tip: `${formatEther(BigInt(t.amount))} ETH`,
rawTimestamp: Number(t.timestamp), // keep raw timestamp for sorting
timestamp: new Date(Number(t.timestamp) * 1000).toLocaleString(),
}));
const sorted = mapped.sort((a, b) => b.rawTimestamp - a.rawTimestamp);
setTestimonials(sorted);
} catch (err) {
console.error('❌ Failed to fetch testimonials:', err);
}
};
fetchTestimonials();
}, []);
useEffect(() => {
console.log('testimonials', testimonials);
}, [testimonials]);
return (
<div>
<Navbar />
<TestimonialForm
onSubmitted={() => setOpen(false)}
setTestimonials={setTestimonials}
/>
</div>
);
};
export default Dashboard;
查看此提交:Feat: Fetch and display testimonials from smart contract · Yash-verma18/workledger-frontend@861dab9
现在我们正在从合约中获取评价到 UI:
现在我们已经获取了评价,让我们用一些样式来展示它们。
首先,我们需要一些资源来使我们的评价卡片在视觉上更具吸引力。
🖼️ 你可以从 GitHub 存储库下载所需的资源:
avatar.svg
bg-card.jpg
📁 将这两个文件都放在你的 public/
文件夹中,我们将在我们的评价卡片组件中使用它们。
在资源就位后,我们将遍历评价并通过自定义设计渲染每个评价。
现在安装 framer motion ⭐,它将成为我们前端存储库中的一个游戏规则改变者。
npm i framer-motion
现在,让我们为我们的卡片添加主要的核心组件。
在你的组件目录中创建一个文件夹 "grids",并创建一个名为 tilted-card.tsx 的文件。
// src/components/grids/tilted-card.tsx
"use client";
import type { SpringOptions } from "framer-motion";
import React, { useRef, useState, FC, ReactNode } from "react";
import { motion, useMotionValue, useSpring } from "framer-motion";
interface TiltedCardProps {
imageSrc: React.ComponentProps<"img">["src"];
altText?: string;
captionText?: string;
containerHeight?: React.CSSProperties['height'];
containerWidth?: React.CSSProperties['width'];
imageHeight?: React.CSSProperties['height'];
imageWidth?: React.CSSProperties['width'];
scaleOnHover?: number;
rotateAmplitude?: number;
showMobileWarning?: boolean;
showTooltip?: boolean;
overlayContent?: ReactNode;
displayOverlayContent?: boolean;
className?: string;
tooltipClassName?: string; // 添加了用于工具提示主题的类名
}
const springValues: SpringOptions = {
damping: 30,
stiffness: 100,
mass: 2,
};
export const TiltedCard: FC<TiltedCardProps> = ({
imageSrc,
altText = "倾斜的卡片图片",
captionText = "",
containerHeight = "300px",
containerWidth = "100%",
imageHeight = "300px",
imageWidth = "300px",
scaleOnHover = 1.1,
rotateAmplitude = 14,
showMobileWarning = true,
showTooltip = true,
overlayContent = null,
displayOverlayContent = false,
className = "",
tooltipClassName = "bg-white text-[#2d2d2d] dark:bg-neutral-800 dark:text-neutral-200", // 默认主题感知的工具提示
}) => {
const ref = useRef<HTMLElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateX = useSpring(useMotionValue(0), springValues);
const rotateY = useSpring(useMotionValue(0), springValues);
const scale = useSpring(1, springValues);
const opacity = useSpring(0);
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1,
});
const [lastY, setLastY] = useState(0);
function handleMouse(e: React.MouseEvent<HTMLElement>) {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const offsetXPosition = e.clientX - rect.left - rect.width / 2;
const offsetYPosition = e.clientY - rect.top - rect.height / 2;
const rotationXValue = (offsetYPosition / (rect.height / 2)) * -rotateAmplitude;
const rotationYValue = (offsetXPosition / (rect.width / 2)) * rotateAmplitude;
rotateX.set(rotationXValue);
rotateY.set(rotationYValue);
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
const velocityY = offsetYPosition - lastY;
rotateFigcaption.set(-velocityY * 0.6);
setLastY(offsetYPosition);
}
function handleMouseEnter() {
scale.set(scaleOnHover);
if (showTooltip) opacity.set(1);
}
function handleMouseLeave() {
if (showTooltip) opacity.set(0);
scale.set(1);
rotateX.set(0);
rotateY.set(0);
rotateFigcaption.set(0);
setLastY(0);
}
return (
<figure
ref={ref}
className={`relative [perspective:800px] flex flex-col items-center justify-center ${className}`}
style={{
height: containerHeight,
width: containerWidth,
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showMobileWarning && (
<div className="absolute top-4 text-center text-xs sm:text-sm text-neutral-500 dark:text-neutral-400 block sm:hidden z-10 p-2 bg-white/80 dark:bg-black/80 rounded">
倾斜效果在桌面上效果最佳。
</div>
)}
<motion.div
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale,
}}
>
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 w-full h-full object-cover rounded-[15px] will-change-transform [transform:translateZ(0)]"
/>
{displayOverlayContent && overlayContent && (
<motion.div
className="absolute inset-0 z-[2] will-change-transform [transform:translateZ(30px)]
flex items-center justify-center"
>
{overlayContent}
</motion.div>
)}
</motion.div>
{showTooltip && captionText && (
<motion.figcaption
className={`pointer-events-none absolute left-0 top-0 rounded-[4px]
px-[10px] py-[4px] text-[10px]
opacity-0 z-[3] hidden sm:block shadow-md
${tooltipClassName}`}
style={{
x, y, opacity,
rotate: rotateFigcaption,
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
);
};
现在让我们为我们的卡片创建一个栅格组件以进行渲染,
路径:src/components/grids/TestimonialsGrid.tsx
'use client';
import Image from 'next/image';
import { useState } from 'react';
import { TiltedCard } from './tilted-card';
import { TestimonialType } from '@/lib/type';
type Props = {
testimonials: TestimonialType[];
};
export default function TestimonialsGrid({ testimonials }: Props) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
return (
<div className='w-full py-2 px-12 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6'>
{testimonials.map((t, i) => {
const overlayContent = (
<div className='absolute inset-0 flex flex-col justify-end p-4 bg-gradient-to-t from-neutral-100/80 to-transparent dark:from-black/80 dark:to-transparent rounded-b-[15px] z-10'>
{/* 头像行 */}
<div className='flex items-center gap-3 mb-3'>
<Image
src='/avatar.svg'
alt='头像'
width={40}
height={40}
className='rounded-full'
/>
<div>
<p className='font-bold text-sm text-neutral-800 dark:text-neutral-200'>
{t.name}
</p>
<p className='text-xs text-neutral-500 dark:text-neutral-400'>
{t.from.slice(0, 6)}...{t.from.slice(-4)}
</p>
</div>
</div>
{/* 小费 & 评分 */}
<div className='flex gap-2 text-sm font-semibold mb-2'>
<div className='bg-neutral-100 dark:bg-neutral-800 rounded px-2 py-1 text-neutral-900 dark:text-neutral-100'>
小费: {t.tip}
</div>
<div className='bg-neutral-100 dark:bg-neutral-800 rounded px-2 py-1 text-neutral-900 dark:text-neutral-100'>
⭐ {t.rating}/5
</div>
</div>
{/* 留言 */}
<p className='text-xs font-medium mb-1 text-neutral-800 dark:text-neutral-100'>
{t.message}
</p>
{/* 工作描述 */}
<div className='text-sm mt-2'>
<p className='text-xs text-neutral-500 dark:text-neutral-400 uppercase'>
工作详情
</p>
{t.workDescription.length > 50 ? (
<span className='font-bold text-neutral-900 dark:text-white'>
{expandedIndex === i ? (
<>
{t.workDescription}
<button
onClick={() => setExpandedIndex(null)}
className='text-sm text-blue-600 dark:text-blue-300 underline ml-1'
>
更少..
</button>
</>
) : (
<>
{t.workDescription.slice(0, 30)}...
<button
onClick={() => setExpandedIndex(i)}
className='text-sm text-blue-600 dark:text-blue-300 underline ml-1'
>
更多..
</button>
</>
)}
</span>
) : (
<span className='font-bold text-neutral-900 dark:text-white'>
{t.workDescription}
</span>
)}
</div>
{/* 时间戳 */}
<p className='text-[10px] text-right mt-2 text-neutral-500 dark:text-neutral-400'>
{t.timestamp}
</p>
</div>
);
return (
<TiltedCard
key={i}
imageSrc='/bg-card.jpg'
altText='评价卡片'
captionText={`⭐ ${t.rating}/5`}
containerHeight='340px'
containerWidth='100%'
imageHeight='100%'
imageWidth='100%'
scaleOnHover={1.07}
rotateAmplitude={12}
showMobileWarning={false}
showTooltip={false}
overlayContent={overlayContent}
displayOverlayContent={true}
/>
);
})}
</div>
);
}
很好,现在我们已经准备好我们的卡片布局和栅格组件可以使用了。
让我们为 Navbar 添加一些更新,因为我们将使用这些链接来处理我们 Dashboard 页面上的组件。
只需更新你的 Navbar.tsx,更新类似于包含一个 setOpen 函数来控制评价表单的可见性。
'use client';
import { useDisconnect } from 'wagmi';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from './breadcrumb';
import { Home } from 'lucide-react';
interface NavbarProps {
setOpen: (state: boolean) => void;
}
function Navbar({ setOpen }: NavbarProps) {
const { disconnect } = useDisconnect();
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href='/' onClick={() => disconnect()}>
<Home strokeWidth={2} aria-hidden='true' />
<span className='sr-only'>断开连接</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator> / </BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink
style={{ cursor: 'pointer' }}
onClick={() => {
setOpen(false);
}}
>
仪表盘
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator> / </BreadcrumbSeparator>
<BreadcrumbItem
style={{ cursor: 'pointer' }}
onClick={() => {
setOpen(true);
}}
>
<BreadcrumbLink>发表评价</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
export { Navbar };
现在更新 page.tsx,因为要集成 `TestimonialsGrid` 并管理评价表单的可见性。
'use client';
import TestimonialForm from '@/components/forms/TestimonialForm';
import { Navbar } from '@/components/navbar/Navbar';
import { TestimonialType } from '@/lib/type';
import React, { useEffect, useState } from 'react';
import { readContract } from 'viem/actions';
import { usePublicClient } from 'wagmi';
import { WORKLEDGER_ADDRESS } from '@/lib/constants';
import { WorkLedgerABI } from '@/lib/WorkLedgerABI';
import { formatEther } from 'viem';
import TestimonialsGrid from '@/components/grids/TestimonialsGrid';
const Dashboard = () => {
const [testimonials, setTestimonials] = useState<TestimonialType[]>([]);
const [showTestimonialForm, setOpen] = useState(false);
const publicClient = usePublicClient();
useEffect(() => {
if (!publicClient) return;
const fetchTestimonials = async () => {
try {
const data = await readContract(publicClient, {
address: WORKLEDGER_ADDRESS,
abi: WorkLedgerABI,
functionName: 'getAllTestimonials',
});
console.log('data', data);
const mapped = (data as any[]).map((t: any) => ({
from: t.from,
name: t.name,
message: t.message,
workDescription: t.workDescription,
rating: Number(t.rating),
tip: `${formatEther(BigInt(t.amount))} ETH`,
rawTimestamp: Number(t.timestamp), // keep raw timestamp for sorting
timestamp: new Date(Number(t.timestamp) * 1000).toLocaleString(),
}));
const sorted = mapped.sort((a, b) => b.rawTimestamp - a.rawTimestamp);
setTestimonials(sorted);
} catch (err) {
console.error('❌ Failed to fetch testimonials:', err);
}
};
fetchTestimonials();
}, []);
useEffect(() => {
console.log('testimonials', testimonials);
}, [testimonials]);
return (
testimonials?.length > 0 && (
<div>
<div className='w-full h-15 relative flex items-center px-6 z-10 rounded-4xl mt-2 '>
<Navbar setOpen={setOpen} />
</div>
<p className='text-2xl md:text-4xl lg:text-7xl text-white font-bold inter-var text-center'>
你的评价
</p>
<p className='text-base md:text-lg mt-4 text-white font-normal inter-var text-center'>
利用链上工作评价的力量。
</p>
{!showTestimonialForm && (
<div className='mt-10'>
<TestimonialsGrid testimonials={testimonials} />
</div>
)}
{showTestimonialForm && (
<TestimonialForm
onSubmitted={() => setOpen(false)}
setTestimonials={setTestimonials}
/>
)}
{showTestimonialForm && (
<TestimonialForm
onSubmitted={() => setOpen(false)}
setTestimonials={setTestimonials}
/>
)}
</div>
)
);
};
export default Dashboard;
让我们来看看这段最终代码的作用,因为这是所有东西汇集在一起的地方。
这个 Dashboard.tsx
组件主要做三件事:
我们使用 Wagmi 中的 usePublicClient()
来访问连接到 Sepolia 的公共 JSON-RPC 提供程序。
然后我们使用 Viem 中的 readContract()
来调用我们智能合约的 getAllTestimonials()
函数。
这将拉取所有先前在链上提交的评价。
const data = await readContract(publicClient, {
address: WORKLEDGER_ADDRESS,
abi: WorkLedgerABI,
functionName: 'getAllTestimonials',
});
合约返回原始数据,因此我们:
rating
和 timestamp
转换为可用的数字formatEther
格式化 amount
(ETH 小费)testimonials
状态中这使得数据可以进行干净的前端渲染。
我们使用条件渲染,基于用户是否想要提交新的评价(showTestimonialForm
)或查看网格。
如果表单打开,则渲染它。如果未打开,我们则显示网格。
简单的逻辑,动态体验。
Navbar
,TestimonialsGrid
,TestimonialForm
)进行清楚地分解一个实时、完全连接的仪表板,它可以:
这是你的 WorkLedger 构建中的最后一块拼图。
你现在拥有一个从头开始构建的工作、美观的链上评价系统。💥
所以最后,这就是你的最终产品看起来的样子。
所以将其部署在 Vercel 上;就像这样,你已经构建了一个功能齐全,端到端的 Web3 DApp:
你不仅编写了 Solidity:你设计了整个用户旅程,从钱包连接到价值转移到社会证明。
WorkLedger 不仅仅是一个演示:它是一种声明。
它表明反馈、声誉和信任可以是透明的、永久的且去中心化的。
没有中间人。没有虚假评价。只有用代码编写的影响证明。
我们可以在 WorkLedger 之上构建更多内容。这里只是一些想法:
如果这听起来令人兴奋:fork 存储库,构建你的功能,并提出 PR。
让我们一起改进它,一次提交一次。💪
如果你已经按照本指南的全部内容,恭喜你!你刚刚:
我希望本指南可以帮助你学习一些新东西,构建一些真实的东西,甚至激发你在 Web3 之旅中走得更远。
想要更深入地研究或 fork 该项目?
🔗 智能合约存储库:Yash-verma18/workledger-contract
🔗 前端存储库:Yash-verma18/workledger-frontend
如果你喜欢本指南,请随意:
感谢你与我一起阅读、构建和学习。下篇博客见。
>- 原文链接: [blog.blockmagnates.com/h...](https://blog.blockmagnates.com/how-i-built-workledger-a-dapp-for-on-chain-work-reviews-bc0c6a4e50c1)
>- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!