我是如何构建 WorkLedger 的:一个链上工作评价的 DApp

本文作者Yash 通过一个 WorkLedger 的 DApp 案例,详细介绍了如何从零开始构建一个全栈Web3应用,部署智能合约并通过前端与之交互。

👋 嘿,我是 Yash! 今天,我们将构建一些令人兴奋的东西:一个完整的去中心化应用程序 (DApp),它直接连接到智能合约。我们将在公共场合、链上编写、测试、部署和交互。

我们将这样做:

  • 使用 Foundry 编写合约逻辑并进行测试
  • 将其部署在 Ethereum 的 Sepolia 测试网
  • 构建一个直接与之通信的前端
  • 将所有内容托管在 Vercel

最棒的是什么? 我们完全跳过了后端,因为我们不需要它,因为所有数据都存储在区块链上。无需信任。透明。永久。

🛠️ 我们要构建什么?

先睹为快

我们正在创建 WorkLedger : 一个链上平台,任何人都可以为你的工作留下推荐信 + 小费

想象一下:

  • 一个 LinkedIn 推荐,但以不可变的方式存储在以太坊上
  • 一个 Google 评论,但无法删除或伪造
  • 一个 工作证明的公共账本,为你赢得信誉和 ETH

❓什么是链上?

这意味着:

  • ✅ 你的评论是不可变的(无法更改或删除)
  • 🔒 它们是无需信任的(没有中心方控制它们)
  • 🧾 任何人都可以验证谁说了什么、何时说的以及说了多少
  • ⚖️ 即使是负面评论也会保留,因为真相很重要

🚧 构建 WorkLedger 的两个主要步骤:

  1. 智能合约开发: 使用 Foundry 编写、测试和部署合约。
  2. 前端开发: 构建一个干净且现代的 UI,使用 Ethers.js 与合约交互。

不需要后端:所有内容(姓名、消息、评分、小费)都直接从链上存储和查询。

🛠️ 1. 使用 Foundry 进行智能合约开发

✅ 1.1:设置 Foundry 项目

在我们编写任何Solidity代码之前,让我们先设置 Foundry,它会像微风一样处理我们的编译、测试和部署。

1.1.1 安装 Foundry

🎯 目标: 设置 Foundry,以便可以使用 forgecast 构建、测试和部署智能合约。

你只需要**每台机器**执行一次此操作

🔧 安装步骤(跨平台)

  1. 打开你的终端
  2. 运行以下安装脚本:
curl -L https://foundry.paradigm.xyz | bash
  1. 重新启动你的终端(刷新 $PATH 非常重要)
  2. 然后运行:
foundryup

✅ 就是这样。你已经准备好 ForgeCast 了!

1.1.2 初始化项目

让我们创建一个干净的 Foundry 项目:

forge init WorkLedger
cd WorkLedger

1.1.3 清理样板文件

Foundry 为你提供了一些默认文件。我们不会使用它们。

你可以删除或清理:

rm -rf src/Counter.sol test/Counter.t.sol

这为我们提供了一个新的画布,可以开始编写 WorkLedger.sol 合约。

🧰 Foundry 附带什么?

  • forge— 用于编译、测试和部署智能合约
  • cast— 用于与 EVM 兼容的区块链交互(如 Sepolia、Polygon 等)

✅ 验证安装

检查这两个工具是否可用:

forge --version
cast --version

你应该会看到类似以下内容:

如果你看到版本号,则一切就绪,可以继续进行。 🚀

1.2.1:创建一个新的 Foundry 项目

🎯 目标: 初始化一个新的 Foundry 项目目录,作为智能合约开发的基础。

创建 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 现在已准备好构建你的合约。

太好了,让我们继续。

🔹 1.2.2:清理默认文件

🎯 目标: 删除不必要的样板文件,以保持项目干净并仅专注于你的 WorkLedger 合约。

清理 Foundry Scaffold 的步骤:

🧩 步骤 1:删除默认合约

rm src/Counter.sol

🧩 步骤 2:删除默认测试

rm test/Counter.t.sol

🧩 步骤 3:可选 删除默认脚本(除非你需要参考)

rm script/Counter.s.sol

如果你计划重用或参考部署脚本格式,则可以暂时保留此文件,稍后再重命名。

🧠1.3:定义和编写智能合约

🎯 目标: 构建 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() 函数

这就是奇迹发生的地方。

用户调用此函数以:

  • 发送 ETH 小费
  • 提交评论

我们验证:

  • ✅ 实际上有小费(没有白吃午餐的人 😅)
  • ✅ 消息和描述不能为空
  • ✅ 评分在 1-5 之间

如果一切都检查完毕,我们将:

  • 将其保存在链上
  • 发出一个事件,供 UI 拾取

这是一个透明的、永久的、有价值的工作认可。

📬 视图函数

  • getAllTestimonials() → 获取所有推荐信
  • getMyTestimonials(address) → 获取特定用户的推荐信

可以将其视为LinkedIn 背书,但不可阻挡。

💸 withdrawTips()

所有小费都存在于合约中。此函数允许所有者提取累积的 ETH。

当然,受 onlyOwner 保护。

🔐 安全措施

  • 来自 OpenZeppelin 的 nonReentrant 修饰符,用于防止重入攻击
  • receive() 回退会恢复直接 ETH 转账——这里没有后门

Openzepplin 导入错误

你的合约中可能存在此导入错误:

要解决此问题,请运行以下命令以安装 openzepplin 包。

forge install OpenZeppelin/openzeppelin-contracts

它会将 openZepplin 合约安装在你的工作目录中。另请确保在你的 .gitignore 文件中添加“lib/”。否则,它会将所有 oz 合约推送到 github。

继续。让我们使用 foundry 测试我们的合约。

🧪 1.4:为智能合约编写单元测试

🎯 目标: 确保 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();
    }
}

🔍 WorkLedger 测试分解:确保信任和可靠性

好了,合约完成了。看起来很整洁。

但我们不提供感觉,我们提供经过实战考验的代码

因此,让我们使用 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
}

每次测试都以干净的状态开始:

  • 我们部署一个新的合约
  • 将所有权分配给我们的测试
  • 使用 5 ETH 资助一个虚假用户(打赏者)进行测试

testLeaveValidTestimonial

workLedger.leaveTestimonial{value: 1 ether}(...)

此测试确认:

  • ETH 已发送
  • 消息、姓名、描述和评分已正确存储
  • 可以获取并验证推荐信

💡 这是“一切正常”的情况。

❌ Revert 测试(极端情况防御者)

每个测试都确保我们不会让任何事情溜走。

  • 空消息"Message cannot be empty"
  • 空工作描述"Work description required"
  • 无效评分(例如,0 或 6)"Rating must be between 1 and 5"
  • 零 ETH 小费"Tip must be greater than 0"
  • 缺少姓名"Name cannot be empty"

📛 这些测试有助于防止垃圾数据永久进入区块链。

💰 testWithdrawTips:ETH 提现检查

  • 打赏者支付 2 ETH + 推荐信
  • 所有者检查余额并调用 withdrawTips()
  • 我们断言:
  • 合约余额变为零
  • 所有者的钱包余额增加

🧾 这是发薪日,但前提是你是部署合约的人。

🔐 testWithdrawFailsIfNotOwner:访问控制检查

  • 随机用户尝试提现
  • 应该回退并显示:"Only owner can perform this action"

因为,说实话,不是你的合约,就不是你的币。

✅ 总结

你的测试涵盖:

  • 正常路径(有效使用)
  • 🧪 所有极端情况
  • 🚫 正确的错误处理
  • 🔐 访问控制
  • 💸 资金管理

🛡️ 你不仅仅是在构建一个 DApp,你还在锁定它。

🎯 总结

所以,是的,这些测试涵盖:

  • ✅ 有效的评论提交
  • ❌ 极端情况失败
  • 🔐 仅所有者权限
  • 💰 ETH 处理和余额

运行测试

如果你使用此命令运行所有这些测试,并且它们通过,那么你就可以确定了。

forge test -vv

你应该会看到所有通过的测试和详细的日志。

接下来,我们将将其部署到 Sepolia,并使其在测试网上生效。准备好进入部署了吗?

1.5:将合约部署到 Sepolia 测试网

🎯 目标: 使用 Foundry 将 WorkLedger 合约部署到 Sepolia 测试网,以便稍后我们可以通过前端与之交互。

1.5.1:使用私钥和 RPC 设置你的 .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

  • 打开 MetaMask
  • 选择你要使用的帐户。
  • 切换到 Sepolia 或任何测试网
  • 顶部网络下拉列表 → 选择 Sepolia 或其他测试网。
  • 导出私钥
  • 单击 3 个点(在你的帐户名称旁边)。
  • 转到帐户详细信息 → 单击导出私钥
  • 输入你的 MetaMask 密码。
  • 复制显示的私钥。

⚠️ 不要公开分享此密钥。 即使它是一个测试网帐户,也应将其视为敏感信息。

将其保存在 .env 中(用于 Foundry)

如何获取 YOUR_PROJECT_ID?

  1. 转到 https://dashboard.alchemy.com
  2. 创建一个新的应用程序
  3. 选择 EthereumSepolia
  4. 转到你的应用程序 → 复制 HTTP URL:
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,以防止将 .env 发送到 github

最新的 .gitignore 文件

## Compiler files
cache/
out/
lib/
node_modules/

## Ignores development broadcast logs
!/broadcast
/broadcast/*/31337/
/broadcast/**/dry-run/

## Docs
docs/

## Dotenv file
.env

1.5.2:更新 foundry.toml 以使用 Sepolia

在你的 foundry.toml 中,添加:

[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"

这将 Sepolia RPC 链接到 Foundry 的网络别名系统。

1.5.3:创建一个部署脚本

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());
    }
}

1.5.4:部署它

在部署之前,请确保你有一些 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 如果你要签名和广播交易,则需要

正如你所看到的,这对于以下方面非常有用:

  • 气体估算
  • 在发送真实 tx 之前验证逻辑
  • 查看合约是否已正确编译和运行

通过广播(真实部署)

--broadcast 将交易发送到真实网络(例如 Sepolia)

没有 --broadcast 模拟使用当前网络状态的脚本(试运行)

这将:

  1. 部署合约
  2. 将交易广播到 Sepolia

部署后

Foundry 将向你显示合约地址。保存此地址,你将在前端中使用它。

在此处查看我的合约:Address 0x788f7F2367122e77eeFAE829f65B21701CdF4B74 | Etherscan

🔍 1.6:在 Sepolia Etherscan 上验证合约

🎯 目标:sepolia.etherscan.io 上验证你的合约,以便人们(和你的前端)可以读取合约代码、调用视图函数并信任其合法性。

🔸 1.6.1:获取你的 Etherscan API 密钥

前往 https://etherscan.io/myapikey,登录并获取你的与 Sepolia 兼容的 API 密钥。

然后将其添加到你的 .env 中:

ETHERSCAN_API_KEY=your_key_here

🔸 1.6.2:将其添加到 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 合约地址时,你将看到:

  • 经过验证的合约代码
  • 一个包含所有函数的Write ContractRead Contract 选项卡

WorkLedger | Address 0xfaba9fcaaa6b2c7f3716b4b09f3a26b666eb8842 | Etherscan

🎉 万岁,你做到了!

如果你已经走到这一步,请花点时间祝贺自己。你已经正式编写、测试、保护、部署和验证了智能合约,该合约在区块链上存储不可变的评论和 ETH 小费。

💯 智能合约之旅现已完成

📚 GitHub 参考

想查看完整的代码库或克隆它吗?

🔗 在 GitHub 上查看存储库 →

⚙️ 接下来:让它焕发生机

是时候转换思路了,让我们构建一个前端,以便在浏览器中与这个野兽交互。我们即将把 WorkLedger 从逻辑 → 实时体验。

让我们使其可视化。让我们使其具有交互性。

🚀 让我们构建 UI。

从智能合约到用户界面

🖥️ 2.1:使用 Next.js 进行前端设置

🎯 目标: 启动一个新的 Next.js 应用程序,我们将在其中构建 UI 以与我们在 Sepolia 测试网上部署的 WorkLedger 智能合约进行交互。

🔹 2.1.1:初始化一个新项目

选择你的工作区目录并在你的终端中运行以下命令:

npx create-next-app@latest workledger-frontend --typescript

出现提示时:

  • ✅ 选择 TypeScript
  • ❌ 对 Tailwind 说 No (我们稍后将使用 Shadcn UI自行安装和配置它)
  • ✅ 如果出现提示,请选择 src/ 目录布局

💡 我们跳过了默认的 Tailwind 设置,以便稍后可以使用更新、更模块化的 Shadcn 设置。更清洁。可扩展。更易于维护。

然后移入项目:

cd workledger-frontend

🔸 2.1.2:清理默认文件

删除样板垃圾:

rm -rf src/app/favicon.ico src/app/page.tsx

删除公共文件夹中的所有内容。你将使用你自己的布局和组件替换它们。

🔸 2.1.3:初始化 Git 和提交

可选但始终是一个好主意:

git add .
git commit -m "Init: fresh Next.js setup for WorkLedger frontend"

✅ 这就是设置的全部内容

现在你有一个干净的、支持 TypeScript 的 Next.js 应用程序,可以集成你的链上合约。

很好,让我们让它变得美观且对开发友好。

🎨 2.2:安装 TailwindCSS 和 Shadcn UI

🎯 目标: 添加 TailwindCSS 以实现实用程序优先的样式设置,并集成 Shadcn UI 组件以加快干净、可访问的 UI 开发速度。

🔸 2.2.1:安装 TailwindCSS

运行官方 Tailwind 设置:

npm install tailwindcss @tailwindcss/postcss postcss
npx tailwindcss-cli init -p

这将创建:

  • tailwind.config.js
  • postcss.config.js

现在,配置 Tailwind 以与你的应用程序一起使用。

🔸 2.2.2:配置 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

🔸 2.2.3:安装 Shadcn UI

运行设置:

npx shadcn@latest init

按照提示操作:

  • 选择首选主题(你始终可以稍后更改它)

如果出现此警告,请按 Enter。(使用 force)

![](https://img.learnblockchain.cn/2025/06/24完成了,我们将集成钱包连接。开始吧!🔗

🦄 2.3:添加 RainbowKit 和 Wagmi 以实现钱包连接

🎯 目标: 允许用户连接他们的以太坊钱包(如 MetaMask)以与链上的 WorkLedger 合约交互。

🔸 2.3.1:安装所需的包

yarn add wagmi viem @rainbow-me/rainbowkit

或者如果使用 npm:

npm install wagmi viem @rainbow-me/rainbowkit

这会给你:

  • RainbowKit 用于精美的钱包用户界面
  • Wagmi 用于与以太坊交互的 React hooks
  • Viem 用于改进的 EVM 兼容性

🔸 2.3.2:使用 Providers 包装你的应用

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

🔸添加 Wallet Connect 按钮

在组件文件夹中的任何位置,创建一个名为: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 或选择你喜欢的任何一个。

连接后,你将看到你的余额和你的地址。

✅ 完成了,钱包集成完成!

用户现在可以:

  • 🔗 连接他们的 MetaMask 钱包
  • 👀 查看他们连接的钱包地址
  • 🧪 在 Sepolia 测试网 上进行交互,因此不存在丢失真实 ETH 的风险
  • ⚙️ 使用 Wagmi hooks 与你的智能合约进行通信

你已准备好在此连接的基础上开始构建功能。开始吧!

你可以在这里比较你的代码差异:Feat: Integrate RainbowKit for wallet connection · Yash-verma18/workledger-frontend@d1f1444

设置仪表板页面

我们将在 /dashboard 创建新路由。

在 Next.js(App Router)中,路由被处理为 app/ 目录中的文件夹

所以让我们执行以下操作:

  1. app/ 文件夹中,创建一个名为 dashboard 的新文件夹
  2. 在其中创建一个名为 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?

用简单的语言来说:

ABI (Application Binary Interface) 就像一个 蓝图接口,它告诉你的应用程序:

  • 合约是关于什么的
  • 它公开了哪些函数
  • 它期望哪些参数
  • 以及如何调用这些函数

没有 ABI,你的前端将不知道如何与合约“对话”。

🔍 在哪里获取 ABI?

这是我们已部署合约的 Sepolia 地址:

0xFaBa9FcAAa6B2C7f3716b4B09F3A26b666eb8842

现在前往 Etherscan Sepolia

  • 单击 “Code” 选项卡
  • 向下滚动到 “Contract ABI” 部分
  • 📋 复制整个 ABI JSON

我们将在我们的前端代码中使用此 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;

查看此提交:Feat: Integrate testimonial submission with smart contract · Yash-verma18/workledger-frontend@057f20a

它现在应该看起来像这样:

🎉 精彩的工作!

如果你已经做到了这一步,认真地,给自己一个鼓励。🙌

看到你自己的智能合约在运行,并真正在链上与之交互,这是一种超现实的感觉。

做你自己最好的朋友,庆祝这个里程碑。你做到了。 👏👏

好了,继续前进,乐趣才刚刚开始。让我们保持这种势头。🚀

🧲 从智能合约中获取评价

我们现在要做的是:

  • 获取链上存储的评价
  • 使用我们的自定义卡片组件对它们进行格式化和美观地展示

🛠 步骤 1:从合约中获取数据

我们将更新我们的 app/dashboard/page.tsx 以执行以下操作:

  1. 使用 Wagmi 中的 usePublicClient
  2. 使用 Viem 中的 readContract 来调用智能合约
  3. 将原始响应映射到类型化的 TestimonialType[] 数组
  4. 使用 formatEther 格式化小费金额
  5. 使用 toLocaleString()timestamp 转换为人类可读的格式
  6. 按时间戳降序排列评价
  7. 将格式化的评价存储在组件状态中
  8. 将它们记录到控制台中以进行调试

完成此操作后,你将在仪表板中看到由你的智能合约提供支持的真实链上数据。💪

'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:

🧩 步骤 2:使用自定义卡片组件显示评价

现在我们已经获取了评价,让我们用一些样式来展示它们

首先,我们需要一些资源来使我们的评价卡片在视觉上更具吸引力。

🖼️ 你可以从 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 组件主要做三件事:

🔌 1. 连接到区块链

我们使用 Wagmi 中的 usePublicClient() 来访问连接到 Sepolia 的公共 JSON-RPC 提供程序。

然后我们使用 Viem 中的 readContract() 来调用我们智能合约的 getAllTestimonials() 函数。

这将拉取所有先前在链上提交的评价

const data = await readContract(publicClient, {
  address: WORKLEDGER_ADDRESS,
  abi: WorkLedgerABI,
  functionName: 'getAllTestimonials',
});

🧹 2. 清理和格式化数据

合约返回原始数据,因此我们:

  • 🔢 将 ratingtimestamp 转换为可用的数字
  • 💸 使用 formatEther 格式化 amount(ETH 小费)
  • 📆 将 UNIX 时间戳转换为人类可读的日期
  • 📦 将最终格式化的列表存储在 testimonials 状态中
  • 📊 按最新优先排序评价

这使得数据可以进行干净的前端渲染。

💬 3. 渲染 UI

我们使用条件渲染,基于用户是否想要提交新的评价(showTestimonialForm)或查看网格。

  • Navbar 位于顶部并切换表单可见性
  • TestimonialsGrid 以美观的方式显示所有评价
  • TestimonialForm 允许用户提交新的评价

如果表单打开,则渲染它。如果未打开,我们则显示网格。

简单的逻辑,动态体验。

✨ 额外润色

  • 🧪 你可以在开发时在控制台中看到日志
  • 🧠 这种结构将所有内容都保存在单个 React 组件中,但它通过导入的组件(NavbarTestimonialsGridTestimonialForm)进行清楚地分解
  • ♻️ 它是可重用的且易于缩放(例如,添加分页、过滤器等)

🚀 结果?

一个实时、完全连接的仪表板,它可以:

  • 从你部署的智能合约中读取
  • 显示实时链上数据
  • 允许用户提交不可变的评价
  • 并且在执行此操作时看起来很干净

这是你的 WorkLedger 构建中的最后一块拼图

你现在拥有一个从头开始构建的工作、美观的链上评价系统。💥

所以最后,这就是你的最终产品看起来的样子。

🎯 结束:你的 WorkLedger DApp 已上线!

所以将其部署在 Vercel 上;就像这样,你已经构建了一个功能齐全,端到端的 Web3 DApp

  • ✅ 一个永远存在于区块链上的智能合约
  • ✅ 链上评价和 ETH 小费,以不可变的方式存储
  • ✅ 一个安全的、样式化的前端,由 Next.jsTailwindCSSShadcnWagmi 提供支持
  • ✅ 使用 RainbowKit 的无缝钱包连接
  • ✅ 真实的合约交互:提交评价,获取它们,甚至兑现小费!

你不仅编写了 Solidity:你设计了整个用户旅程,从钱包连接到价值转移到社会证明

💬 为什么这个项目很重要

WorkLedger 不仅仅是一个演示:它是一种声明。

它表明反馈、声誉和信任可以是透明的、永久的且去中心化的。

没有中间人。没有虚假评价。只有用代码编写的影响证明

🔗 想要贡献?

我们可以在 WorkLedger 之上构建更多内容。这里只是一些想法:

  • 📱 使其具有移动响应性
  • 🔍 添加链上过滤(评级、搜索等)
  • 🧑‍🎨 创建艺术家/开发者个人资料页面
  • 🛡️ 为经过验证的评价添加Token门控提交或 NFT 徽章
  • 🌐 将其部署到主网并连接你的网络

如果这听起来令人兴奋:fork 存储库,构建你的功能,并提出 PR

让我们一起改进它,一次提交一次。💪

🙌 结语

如果你已经按照本指南的全部内容,恭喜你!你刚刚:

  • 使用 Foundry 编写并测试了一个智能合约
  • 从头开始构建了一个完整的 Web3 应用程序
  • 将你的愿景实时部署在区块链上

我希望本指南可以帮助你学习一些新东西,构建一些真实的东西,甚至激发你在 Web3 之旅中走得更远。

🧠 探索代码

想要更深入地研究或 fork 该项目?

🔗 智能合约存储库:Yash-verma18/workledger-contract

🔗 前端存储库:Yash-verma18/workledger-frontend

保持联系

如果你喜欢本指南,请随意:

  • ⭐ Star 存储库
  • 🐦 在 Twitter 上关注我

感谢你与我一起阅读、构建和学习。下篇博客见。



>- 原文链接: [blog.blockmagnates.com/h...](https://blog.blockmagnates.com/how-i-built-workledger-a-dapp-for-on-chain-work-reviews-bc0c6a4e50c1)
>- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
blockmagnates
blockmagnates
江湖只有他的大名,没有他的介绍。