名称: 前端用户体验 描述: 用于防止最常见AI代理UI错误的以太坊去中心化应用前端用户体验规则。包括链上按钮、代币批准流程、地址显示、美元价值、RPC配置和发布前元数据的强制模式。围绕Scaffold-ETH 2构建,但模式适用于任何以太坊前端。在构建任何去中心化应用前端时使用。
前端用户体验规则
你可能出错的地方
“按钮工作。” 工作不是标准。它是否在交易期间禁用?是否显示加载指示器?是否在链上确认前保持禁用?如果用户拒绝是否显示错误?AI代理每次都会跳过所有这些。
“我用了wagmi钩子。” 错误的钩子。Scaffold-ETH 2通过useTransactor包装wagmi,它等待交易确认——不仅仅是钱包签名。原始wagmi的writeContractAsync在用户在MetaMask中点击确认后立即解析,在交易被挖出之前。你的按钮会在交易仍在待处理时重新启用。
“我显示了地址。” 作为原始十六进制?那不是显示它。<Address/>提供ENS解析、区块头像、复制到剪贴板和区块浏览器链接。原始0x1234...5678是不可接受的。
规则1:每个链上按钮——加载器 + 禁用
⚠️ 这是AI代理部署的#1错误。 用户点击批准,在他们的钱包中签名,回到应用,批准按钮再次可点击——所以他们再次点击,发送重复交易,现在两个批准待处理。按钮必须从点击时刻到交易在链上确认期间禁用并显示加载指示器。 不是直到钱包关闭。不是直到签名发送。直到区块确认。
任何触发区块链交易的按钮必须:
- 点击后立即禁用
- 显示加载指示器(“批准中…”、“质押中…”等)
- 保持禁用直到状态更新确认操作完成
- 完成后显示成功/错误反馈
// ✅ 正确:每个操作独立的加载状态
const [isApproving, setIsApproving] = useState(false);
const [isStaking, setIsStaking] = useState(false);
<button
disabled={isApproving}
onClick={async () => {
setIsApproving(true);
try {
await writeContractAsync({ functionName: "approve", args: [...] });
} catch (e) {
console.error(e);
notification.error("批准失败");
} finally {
setIsApproving(false);
}
}}
>
{isApproving ? "批准中..." : "批准"}
</button>
❌ 永远不要为多个按钮使用单个共享的isLoading。 每个按钮都有自己的加载状态。共享状态会导致UI条件切换按钮时显示错误的加载文本。
仅使用Scaffold钩子——永远不要原始Wagmi
// ❌ 错误:原始wagmi——在签名后解析,不是确认
const { writeContractAsync } = useWriteContract();
await writeContractAsync({...}); // 在MetaMask签名后立即返回!
// ✅ 正确:Scaffold钩子——等待交易被挖出
const { writeContractAsync } = useScaffoldWriteContract("MyContract");
await writeContractAsync({...}); // 等待实际链上确认
为什么: useScaffoldWriteContract在内部使用useTransactor,它等待区块确认。原始wagmi不会——你的UI将在交易仍在内存池时显示“成功”。
规则2:四状态流程——连接 → 网络 → 批准 → 操作
当用户需要与应用交互时,有四个状态。每次只显示一个明显的大按钮:
1. 未连接? → 大“连接钱包”按钮(不是文本说“连接你的钱包来玩”)
2. 错误网络? → 大“切换到Base”按钮
3. 批准不足? → “批准”按钮(带加载器,根据规则1)
4. 批准足够? → “质押” / “存款” / 操作按钮
永远不要显示文本提示如“连接你的钱包来玩”或“请连接以继续。” 显示一个按钮。用户应该总是只有一个东西可以点击。
const { data: allowance } = useScaffoldReadContract({
contractName: "Token",
functionName: "allowance",
args: [address, contractAddress],
});
const needsApproval = !allowance || allowance < amount;
const wrongNetwork = chain?.id !== targetChainId;
const notConnected = !address;
{notConnected ? (
<RainbowKitCustomConnectButton /> // 大连接按钮——不是文本
) : wrongNetwork ? (
<button onClick={switchNetwork} disabled={isSwitching}>
{isSwitching ? "切换中..." : "切换到Base"}
</button>
) : needsApproval ? (
<button onClick={handleApprove} disabled={isApproving}>
{isApproving ? "批准中..." : "批准 $TOKEN"}
</button>
) : (
<button onClick={handleStake} disabled={isStaking}>
{isStaking ? "质押中..." : "质押"}
</button>
)}
关键细节:
- 始终通过钩子读取批准额度,以便UI在批准交易确认时自动更新
- 永远不要仅依赖本地状态跟踪批准额度
- 错误网络检查首先进行——如果用户在错误网络点击批准,一切都会中断
- 永远不要同时显示批准和操作按钮——一次一个按钮
规则3:地址显示——总是使用<Address/>
每次显示以太坊地址时,使用scaffold-eth的<Address/>组件:
import { Address } from "~~/components/scaffold-eth";
// ✅ 正确
<Address address={userAddress} />
// ❌ 错误——永远不要渲染原始十六进制
<span>{userAddress}</span>
<p>0x1234...5678</p>
<Address/>处理ENS解析、区块头像、复制到剪贴板、截断和区块浏览器链接。原始十六进制不可接受。
地址输入——总是使用<AddressInput/>
每次用户需要输入以太坊地址时,使用<AddressInput/>:
import { AddressInput } from "~~/components/scaffold-eth";
// ✅ 正确
<AddressInput value={recipient} onChange={setRecipient} placeholder="接收地址" />
// ❌ 错误——永远不要使用原始文本输入处理地址
<input type="text" value={recipient} onChange={e => setRecipient(e.target.value)} />
<AddressInput/>提供ENS解析(输入“vitalik.eth”→解析为地址)、区块头像预览、验证和粘贴处理。
配对:<Address/>用于显示,<AddressInput/>用于输入。总是。
显示你的合约地址
每个去中心化应用应该在其主页面底部显示部署的合约地址使用<Address/>。用户希望验证区块浏览器上的合约。这建立信任并且是标准做法。
<div className="text-center mt-8 text-sm opacity-70">
<p>合约:</p>
<Address address={deployedContractAddress} />
</div>
规则4:处处显示美元价值
每个显示的代币或ETH金额应包括其美元价值。 每个代币或ETH输入应显示实时美元预览。
// ✅ 正确——显示带美元
<span>1,000 TOKEN (~$4.20)</span>
<span>0.5 ETH (~$1,250.00)</span>
// ✅ 正确——输入带实时美元预览
<input value={amount} onChange={...} />
<span className="text-sm text-gray-500">
≈ ${(parseFloat(amount || "0") * tokenPrice).toFixed(2)} USD
</span>
// ❌ 错误——金额无美元上下文
<span>1,000 TOKEN</span> // 用户不知道这值多少
从哪里获取价格:
- ETH价格: SE2内置钩子——
useNativeCurrencyPrice() - 自定义代币: DexScreener API(
https://api.dexscreener.com/latest/dex/tokens/TOKEN_ADDRESS)、链上Uniswap报价器或Chainlink预言机
这适用于显示和输入:
- 显示余额?在它旁边显示美元。
- 用户输入金额发送/质押/交换?在输入下方显示实时美元预览。
- 交易确认?显示他们将要执行的操作的美元价值。
规则5:无重复标题
不要在页面主体顶部将应用名称作为<h1>放置。 SE2头部已经显示应用名称。重复它浪费空间并且看起来业余。
// ❌ 错误——AI代理总是这样做
<Header /> {/* 已经显示“🦞 我的去中心化应用” */}
<main>
<h1>🦞 我的去中心化应用</h1> {/* 重复!删除这个。 */}
<p>应用描述</p>
...
</main>
// ✅ 正确——直接进入内容
<Header /> {/* 显示应用名称 */}
<main>
<div className="grid grid-cols-2 gap-4">
{/* 统计、余额、操作——无冗余标题 */}
</div>
</main>
规则6:RPC配置
永远不要使用公共RPC(mainnet.base.org等)——它们会限速并导致生产中的随机故障。
在scaffold.config.ts中,始终设置:
rpcOverrides: {
[chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org",
},
pollingInterval: 3000, // 3秒,不是默认的30000
将API密钥保存在.env.local中——永远不要硬编码在提交到Git的配置文件中。
⚠️ SE2的
wagmiConfig.tsx添加了一个裸http()(无URL)作为后备传输。 Viem将裸http()解析为链的默认公共RPC(例如Base的mainnet.base.org)。即使在scaffold配置中设置了rpcOverrides,公共RPC仍将被命中,因为viem的fallback()并行触发传输。你必须从services/web3/wagmiConfig.tsx的后备数组中移除裸http(),以便只使用你配置的RPC。如果你不这样做,你的应用将在每个轮询周期中向公共RPC发送请求,并在生产中被429限速。
监控RPC使用: 合理 = 每3秒一次请求。如果你看到15+请求/秒,你有错误:
- 钩子在循环中重新渲染
- 重复钩子调用
- 缺少依赖数组
- 不需要的钩子上设置
watch: true
规则7:发布前检查清单
在将前端部署到生产之前,每个项目必须通过:
开放图 / Twitter卡片(必需):
// 在app/layout.tsx或getMetadata.ts中
export const metadata: Metadata = {
title: "你的应用名称",
description: "应用描述",
openGraph: {
title: "你的应用名称",
description: "应用描述",
images: [{ url: "https://你的生产域名.com/缩略图.png" }],
},
twitter: {
card: "summary_large_image",
title: "你的应用名称",
description: "应用描述",
images: ["https://你的生产域名.com/缩略图.png"],
},
};
⚠️ 开放图图片URL必须:
- 以
https://开头的绝对URL - 实际生产域名(不是
localhost,不是相对路径) - 不是可能未设置的环境变量
- 实际可达(通过浏览器访问URL测试)
移除所有Scaffold-ETH 2默认身份:
- [ ] README重写——不是SE2模板README
- [ ] 页脚清理——移除BuidlGuidl链接、“Fork me”链接、支持链接、任何SE2品牌。替换为你的项目仓库链接
- [ ] 网站图标更新——不是SE2默认
- [ ] 标签标题是你的应用名称——不是“Scaffold-ETH 2”
完整检查清单:
- [ ] 开放图图片URL是绝对的、实际生产域名
- [ ] 开放图标题和描述设置(不是默认SE2文本)
- [ ] Twitter卡片类型设置(
summary_large_image) - [ ] 所有SE2默认品牌移除(README、页脚、网站图标、标签标题)
- [ ] 浏览器标签标题正确
- [ ] RPC覆盖设置(不是公共RPC)
- [ ] 从wagmiConfig.tsx后备数组中移除裸
http()(无静默公共RPC后备) - [ ]
pollingInterval为3000 - [ ] 所有合约地址与部署的匹配
- [ ] 生产代码中无硬编码测试网/localhost值
- [ ] 每个地址显示使用
<Address/> - [ ] 每个地址输入使用
<AddressInput/> - [ ] 每个链上按钮有自己的加载器 + 禁用状态
- [ ] 批准流程有网络检查 → 批准 → 操作模式
- [ ] 无重复h1标题匹配头部
externalContracts.ts — 在构建之前
所有外部合约(代币、协议、任何你未部署的)必须在构建前端之前添加到packages/nextjs/contracts/externalContracts.ts,带地址和ABI。
// packages/nextjs/contracts/externalContracts.ts
export default {
8453: { // Base链ID
USDC: {
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
abi: [...], // ERC-20 ABI
},
},
} as const;
为什么在之前: Scaffold钩子(useScaffoldReadContract、useScaffoldWriteContract)只与在deployedContracts.ts(自动生成)或externalContracts.ts(手动)中注册的合约一起工作。如果你编写引用未注册合约的前端代码,它会静默失败。
永远不要编辑deployedContracts.ts——它由yarn deploy自动生成。将你的外部合约放在externalContracts.ts中。
人类可读金额
始终在合约单位和显示单位之间转换:
// 合约 → 显示
import { formatEther, formatUnits } from "viem";
formatEther(weiAmount); // 18小数(ETH、DAI、大多数代币)
formatUnits(usdcAmount, 6); // 6小数(USDC、USDT)
// 显示 → 合约
import { parseEther, parseUnits } from "viem";
parseEther("1.5"); // → 1500000000000000000n
parseUnits("100", 6); // → 100000000n(USDC)
永远不要向用户显示原始wei/单位。 1500000000000000000毫无意义。1.5 ETH (~$3,750)意味着一切。
资源
- SE2文档: https://docs.scaffoldeth.io/
- UI组件: https://ui.scaffoldeth.io/
- SpeedRun Ethereum: https://speedrunethereum.com/