Web3前端实战项目,做一个简易的链上看板。
本教程详细介绍了如何使用Next.js搭建链上看板系统。主要内容包括:1) 环境准备(Node.js/npm安装和国内镜像配置);2) 使用create-next-app创建项目;3) 实现核心功能组件:主页布局、关键指标卡片(调用Binance API获取数据)和K线图表(使用ApexCharts库);4) 集成WebSocket实现实时数据更新。教程提供了完整的代码实现,包括响应式设计、骨架屏
·
这篇教程旨在让新手也能搭建一个简易的链上看板:
部署环境准备
1.安装npm和node.js
2.安装next.js
在开始之前,确保你已经安装了 Node.js 和 npm,你可以通过以下命令检查它们是否已经安装
node -v
npm -v
如果你的系统还不支持 Node.js 及 NPM 可以参考我们的 Node.js 教程。
国内使用 npm 速度很慢,你可以使用淘宝定制的 cnpm (gzip 压缩支持) 命令行工具代替默认的 npm:
$ npm install -g cnpm --registry=https://registry.npmmirror.com
$ npm config set registry https://registry.npmmirror.com
这样就可以使用 cnpm 命令来安装模块了:
$ cnpm install [name]
3.使用 create-react-app 快速构建 Next.js 开发环境
npx create-next-app@latest my-next-app
安装时,您将看到以下提示,一路回车即可:
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like your code inside a `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to use Turbopack for `next dev`? No / Yes
Would you like to customize the import alias (`@/*` by default)? No / Yes
What import alias would you like configured? @/*
4.安装ApexCharts依赖
npm install apexcharts react-apexcharts
web应用创建:
1. 创建主页组件 (app/page.tsx)
// app/page.tsx
'use client';
import { useState, useEffect } from 'react';
import ApexCandlestick from '@/components/ApexCandlestick';
import KeyMetrics from '@/components/KeyMetrics';
export default function HomePage() {
const [selectedSymbol, setSelectedSymbol] = useState('BTCUSDT');
const [isMobile, setIsMobile] = useState(false);
// 检测移动端
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
return (
<div className="min-h-screen bg-gray-900 text-white">
{/* 顶部导航栏 */}
<header className="sticky top-0 z-50 bg-gray-800 border-b border-gray-700 px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<span className="font-bold">C</span>
</div>
<h1 className="text-xl font-bold">链上看板</h1>
<span className="text-xs bg-blue-900 text-blue-300 px-2 py-1 rounded">实时</span>
</div>
<div className="hidden md:flex items-center space-x-6">
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-400">连接状态:</span>
<div className="flex items-center">
<div className="w-2 h-2 bg-green-500 rounded-full mr-2"></div>
<span className="text-sm">已连接</span>
</div>
</div>
<div className="text-sm text-gray-400">
上次更新: {new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-6">
{/* 第一行:交易对选择和关键指标 */}
<div className="mb-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
<div>
<h2 className="text-2xl font-bold">市场概览</h2>
<p className="text-gray-400 text-sm">实时监控加密货币价格和交易数据</p>
</div>
<div className="flex flex-wrap gap-4">
<div className="w-full md:w-auto">
<label className="block text-sm text-gray-400 mb-1">选择交易对</label>
<select
value={selectedSymbol}
onChange={(e) => setSelectedSymbol(e.target.value)}
className="bg-gray-800 text-white p-3 rounded-lg w-full border border-gray-700 focus:border-blue-500 focus:outline-none"
>
<option value="BTCUSDT">BTC/USDT - 比特币</option>
<option value="ETHUSDT">ETH/USDT - 以太坊</option>
<option value="BNBUSDT">BNB/USDT - 币安币</option>
<option value="SOLUSDT">SOL/USDT - Solana</option>
<option value="XRPUSDT">XRP/USDT - 瑞波币</option>
<option value="ADAUSDT">ADA/USDT - Cardano</option>
</select>
</div>
<div className="w-full md:w-auto">
<label className="block text-sm text-gray-400 mb-1">时间周期</label>
<select className="bg-gray-800 text-white p-3 rounded-lg w-full border border-gray-700">
<option value="1m">1分钟</option>
<option value="5m">5分钟</option>
<option value="15m">15分钟</option>
<option value="1h">1小时</option>
<option value="4h">4小时</option>
<option value="1d">1天</option>
</select>
</div>
</div>
</div>
{/* 关键指标卡片 */}
<KeyMetrics symbol={selectedSymbol} />
</div>
{/* 第二行:K线图表 */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center">
<span className="mr-2">📈</span> 价格图表
<span className="ml-2 text-sm font-normal text-gray-400">
{selectedSymbol.replace('USDT', '/USDT')}
</span>
</h2>
<div className="flex space-x-2">
<button className="px-3 py-1 bg-gray-800 rounded text-sm hover:bg-gray-700">指标</button>
<button className="px-3 py-1 bg-gray-800 rounded text-sm hover:bg-gray-700">绘图</button>
<button className="px-3 py-1 bg-blue-600 rounded text-sm hover:bg-blue-700">全屏</button>
</div>
</div>
<div className="bg-gray-800 rounded-xl p-4">
<ApexCandlestick />
</div>
</div>
{/* 底部信息 */}
<div className="mt-8 pt-6 border-t border-gray-800 text-center text-gray-500 text-sm">
<p>数据来自 Binance WebSocket API,更新延迟 < 100ms</p>
<p className="mt-1">链上看板 v1.0 • 仅供学习使用</p>
</div>
</main>
</div>
);
}
2.创建关键指标卡片:
'use client';
import { useState, useEffect } from 'react';
interface KeyMetricsProps {
symbol: string;
}
interface TickerData {
price: number;
change: number;
changePercent: number;
high: number;
low: number;
volume: number;
quoteVolume: number;
}
export default function KeyMetrics({ symbol }: KeyMetricsProps) {
const [ticker, setTicker] = useState<TickerData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log(`[KeyMetrics] symbol 变化,当前: ${symbol}`);
const fetchTicker = async () => {
try {
const response = await fetch(
`https://api.binance.com/api/v3/ticker/24hr?symbol=${symbol}`
);
const data = await response.json();
setTicker({
price: parseFloat(data.lastPrice),
change: parseFloat(data.priceChange),
changePercent: parseFloat(data.priceChangePercent),
high: parseFloat(data.highPrice),
low: parseFloat(data.lowPrice),
volume: parseFloat(data.volume),
quoteVolume: parseFloat(data.quoteVolume),
});
// ❗ 只在第一次加载时关闭 loading
if (loading) setLoading(false);
} catch (error) {
console.error('Failed to fetch ticker:', error);
}
};
fetchTicker();
const interval = setInterval(fetchTicker, 5000);
return () => clearInterval(interval);
}, [symbol]);
// 🔹骨架屏只显示第一次加载
if (loading) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-gray-800 rounded-lg p-4 animate-pulse">
<div className="h-4 bg-gray-700 rounded mb-2"></div>
<div className="h-6 bg-gray-700 rounded"></div>
</div>
))}
</div>
);
}
if (!ticker) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-none">
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm mb-1">当前价格</div>
<div className="text-2xl font-bold">
${ticker.price.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
})}
</div>
<div className={`text-sm mt-1 ${ticker.change >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{ticker.change >= 0 ? '▲' : '▼'} {Math.abs(ticker.change).toFixed(2)} ({ticker.changePercent.toFixed(2)}%)
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm mb-1">24H 最高/最低</div>
<div className="text-lg font-semibold">
${ticker.high.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</div>
<div className="text-sm text-gray-300">
${ticker.low.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm mb-1">24H 成交量</div>
<div className="text-lg font-semibold">
{ticker.volume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</div>
<div className="text-sm text-gray-300">
{symbol.replace('USDT', '')}
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm mb-1">24H 成交额</div>
<div className="text-lg font-semibold">
${ticker.quoteVolume.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</div>
<div className="text-sm text-gray-300">USDT</div>
</div>
</div>
);
}
3.创建K线图表组件
// components/ApexCandlestick.tsx
'use client';
import React, { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
// 动态导入避免SSR问题
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
// 定义蜡烛数据类型
interface CandleData {
x: Date;
y: [number, number, number, number]; // 明确指定为4元素元组
}
interface SeriesData {
data: CandleData[];
}
export default function ApexCandlestick() {
const [symbol, setSymbol] = useState('BTCUSDT');
const [series, setSeries] = useState<SeriesData[]>([{ data: [] }]);
const [options] = useState({
chart: {
type: 'candlestick' as const,
height: 400,
background: '#000000ff',
foreColor: '#ffffffff',
toolbar: { show: true }
},
xaxis: {
type: 'datetime' as const,
labels: { style: { colors: '#aaa' } }
},
yaxis: {
tooltip: { enabled: true },
labels: { style: { colors: '#aaa' } }
},
plotOptions: {
candlestick: {
colors: {
upward: '#00b746',
downward: '#ef403c'
}
}
},
tooltip: { theme: 'dark' }
});
useEffect(() => {
// 1. 获取历史数据
const fetchHistory = async () => {
try {
const res = await fetch(
`https://api.binance.com/api/v3/klines?symbol=${symbol}&interval=1h&limit=100`
);
const data = await res.json();
const formatted = data.map((k: any[]): CandleData => ({
x: new Date(k[0]),
y: [
parseFloat(k[1]),
parseFloat(k[2]),
parseFloat(k[3]),
parseFloat(k[4])
] as [number, number, number, number]
}));
setSeries([{ data: formatted }]);
} catch (error) {
console.error('Failed to fetch history data:', error);
}
};
fetchHistory();
// 2. WebSocket实时数据
const ws = new WebSocket(
`wss://stream.binance.com:9443/ws/${symbol.toLowerCase()}@kline_1h`
);
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
const kline = message.k;
const newCandle: CandleData = {
x: new Date(kline.t),
y: [
parseFloat(kline.o),
parseFloat(kline.h),
parseFloat(kline.l),
parseFloat(kline.c)
] as [number, number, number, number]
};
setSeries(prev => {
const currentData = prev[0]?.data || [];
const lastCandle = currentData[currentData.length - 1];
if (lastCandle?.x.getTime() === newCandle.x.getTime()) {
// 更新当前K线
return [{
data: [...currentData.slice(0, -1), newCandle]
}];
} else {
// 添加新K线
return [{
data: [...currentData, newCandle]
}];
}
});
} catch (error) {
console.error('Failed to process WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
return () => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
};
}, [symbol]);
return (
<div>
<select
value={symbol}
onChange={e => setSymbol(e.target.value)}
className="bg-gray-800 text-white p-2 mb-4 rounded"
>
<option value="BTCUSDT">BTC/USDT</option>
<option value="ETHUSDT">ETH/USDT</option>
<option value="BNBUSDT">BNB/USDT</option>
</select>
{typeof window !== 'undefined' && series[0].data.length > 0 ? (
<Chart
options={options}
series={series}
type="candlestick"
height={400}
/>
) : (
<div className="h-[400px] flex items-center justify-center text-gray-400">
正在加载图表数据...
</div>
)}
</div>
);
}
4.启动项目:
npm run dev
npm run start
5.最终实现效果

结语:
如果认为对你有帮助,帮忙点个赞吧!
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)