这篇教程旨在让新手也能搭建一个简易的链上看板:

部署环境准备

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,更新延迟 &lt; 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.最终实现效果

结语:

如果认为对你有帮助,帮忙点个赞吧!

Logo

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

更多推荐