浏览代码

Merge branch 'main' of http://47.76.177.38:48976/Benita/Bit_Wise_World

jhaoG 3 周之前
父节点
当前提交
c9bf4aba5f
共有 1 个文件被更改,包括 2376 次插入0 次删除
  1. 2376 0
      src/views/market/details/MarketConditions.vue

+ 2376 - 0
src/views/market/details/MarketConditions.vue

@@ -58,6 +58,2382 @@
       </div>
     </nav>
 
+    <div class="k-line-main">
+      <KLineChart
+        ref="klineRef"
+        :data="kLineData"
+        height="100%"
+        :precision="{ price: getPricePrecision(marketInfo.price), volume: 2 }" />
+    </div>
+
+    <div class="notifi-classifi">
+      <div
+        class="pf600 fs14"
+        :class="current === 'entrustingOrder' ? 'fc121212' : 'fcA8A8A8'"
+        @click="messageChange('entrustingOrder')">
+        委托挂单
+      </div>
+      <div
+        class="sys-notifi pf600 fs14"
+        :class="current === 'latestTransactions' ? 'fc121212' : 'fcA8A8A8'"
+        @click="messageChange('latestTransactions')">
+        最新成交
+      </div>
+    </div>
+
+    <component
+      :is="currentComponent"
+      :symbol-id="symbolId"
+      :latestTransactionData="latestTransactionData"
+      :orderPlacement="orderPlacement" />
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted, onUnmounted, watch, onBeforeUnmount } from "vue";
+  import { useRoute } from "vue-router";
+  import { GetCandlestickChart } from "@/api/index.js";
+  import EntrustingOrder from "./EntrustingOrder.vue";
+  import LatestTransactions from "./LatestTransactions.vue";
+  import KLineChart from "@/views/bitcoin/lever/components/KLineChart.vue";
+
+  const route = useRoute();
+  const symbolId = computed(() => route.query.id || "6");
+
+  const currentTab = ref("1d");
+  const tabs = ["1h", "6h", "1d", "1w", "1m"];
+  const kLineData = ref([]);
+  const socket = ref(null);
+  const WS_BASE_URL = "ws://backend.66linknow.com/ws/kline/";
+
+  // --- 生产级配置 ---
+  const HEARTBEAT_INTERVAL = 15000; // 心跳间隔 15s
+  const RECONNECT_DELAY = 3000; // 重连延迟 3s
+  let heartbeatTimer = null;
+  let reconnectTimer = null;
+  let isUnmounted = false;
+
+  const marketInfo = ref({
+    price: "0.00",
+    fiatPrice: "0.00",
+    change: 0.0,
+    high: "0.00",
+    low: "0.00",
+    vol: "0",
+    amount: "0",
+  });
+
+  const orderPlacement = ref();
+  const latestTransactionData = ref();
+
+  const current = ref("entrustingOrder");
+  const componentsMap = {
+    entrustingOrder: EntrustingOrder,
+    latestTransactions: LatestTransactions,
+  };
+  const currentComponent = computed(() => componentsMap[current.value]);
+
+  // --- 1. 切换周期 ---
+  const switchPeriod = (period) => {
+    if (currentTab.value === period) return;
+
+    currentTab.value = period;
+
+    // 清空数据,触发子组件重置,并重新请求
+    kLineData.value = [];
+    getKlineData();
+  };
+
+  // --- 2. HTTP 获取历史数据 ---
+  const getKlineData = async () => {
+    if (typeof GetCandlestickChart !== "function") return;
+
+    try {
+      const res = await GetCandlestickChart({
+        symbol: symbolId.value,
+        period: currentTab.value,
+      });
+
+      let rawList = [];
+      if (Array.isArray(res)) rawList = res;
+      else if (res && Array.isArray(res.data)) rawList = res.data;
+
+      if (rawList.length > 0) {
+        const formattedData = rawList.map((item) => ({
+          timestamp: Number(item[0]),
+          open: parseFloat(item[1]),
+          high: parseFloat(item[2]),
+          low: parseFloat(item[3]),
+          close: parseFloat(item[4]),
+          volume: parseFloat(item[5]),
+        }));
+        formattedData.sort((a, b) => a.timestamp - b.timestamp);
+        kLineData.value = formattedData;
+
+        // 同步合并:如果 WS 已经有最新价,立即修正历史数据最后一根,防止回跳
+        if (marketInfo.value.price !== "0.00") {
+          const lastBar = formattedData[formattedData.length - 1];
+          const realTimePrice = parseFloat(marketInfo.value.price);
+          lastBar.close = realTimePrice;
+          // 修正高低
+          if (realTimePrice > lastBar.high) lastBar.high = realTimePrice;
+          if (realTimePrice < lastBar.low) lastBar.low = realTimePrice;
+        }
+
+        updateMarketInfoFromKline(formattedData);
+      }
+    } catch (error) {
+      console.error("API Error", error);
+    }
+  };
+
+  const updateMarketInfoFromKline = (data) => {
+    if (!data.length) return;
+    const lastBar = data[data.length - 1];
+
+    // 仅当没数据或 WS 未连接时使用历史数据兜底
+    if (
+      marketInfo.value.price === "0.00" ||
+      !socket.value ||
+      socket.value.readyState !== 1
+    ) {
+      const firstBar = data[0];
+      let maxHigh = -Infinity,
+        minLow = Infinity,
+        totalVol = 0;
+      data.forEach((item) => {
+        if (item.high > maxHigh) maxHigh = item.high;
+        if (item.low < minLow) minLow = item.low;
+        totalVol += item.volume;
+      });
+      const changeRate = firstBar.open
+        ? ((lastBar.close - firstBar.open) / firstBar.open) * 100
+        : 0;
+
+      marketInfo.value = {
+        price: lastBar.close,
+        change: changeRate.toFixed(2),
+        high: maxHigh,
+        low: minLow,
+        vol: totalVol,
+        amount: totalVol * lastBar.close,
+        fiatPrice: lastBar.close,
+      };
+    }
+  };
+
+  // --- 3. WS 连接 (含心跳设计) ---
+  const connectWebSocket = () => {
+    // 清理旧资源
+    closeWebSocket();
+    const symbolStr = String(route.query.type || "btcusdt").toLowerCase();
+    const url = `${WS_BASE_URL}?symbol=${symbolStr}`;
+
+    console.log("WS 连接:", url);
+
+    try {
+      socket.value = new WebSocket(url);
+
+      socket.value.onopen = () => {
+        console.log("✅ WS Connected");
+        startHeartbeat(); // 启动心跳
+      };
+
+      socket.value.onmessage = (event) => {
+        handleSocketMessage(event.data);
+      };
+
+      socket.value.onclose = () => {
+        stopHeartbeat(); // 停止心跳
+        if (!isUnmounted) {
+          console.log("⚠️ WS Closed, reconnecting...");
+          clearTimeout(reconnectTimer);
+          reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
+        }
+      };
+
+      socket.value.onerror = (err) => {
+        // onerror 通常会触发 onclose,由 onclose 处理重连
+        console.error("❌ WS Error", err);
+      };
+    } catch (e) {
+      if (!isUnmounted) {
+        reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
+      }
+    }
+  };
+
+  const closeWebSocket = () => {
+    if (socket.value) {
+      socket.value.close();
+      socket.value = null;
+    }
+    stopHeartbeat();
+    clearTimeout(reconnectTimer);
+  };
+
+  // --- 心跳逻辑 ---
+  const startHeartbeat = () => {
+    stopHeartbeat();
+    heartbeatTimer = setInterval(() => {
+      if (socket.value && socket.value.readyState === WebSocket.OPEN) {
+        // 发送 ping,具体格式看后端要求,一般是字符串 'ping' 或 JSON
+        socket.value.send("ping");
+      }
+    }, HEARTBEAT_INTERVAL);
+  };
+
+  const stopHeartbeat = () => {
+    if (heartbeatTimer) {
+      clearInterval(heartbeatTimer);
+      heartbeatTimer = null;
+    }
+  };
+
+  // --- 辅助:获取周期对应的毫秒数 ---
+  const getPeriodMs = (period) => {
+    const map = {
+      "1m": 60 * 1000,
+      "5m": 5 * 60 * 1000,
+      "15m": 15 * 60 * 1000,
+      "30m": 30 * 60 * 1000,
+      "1h": 60 * 60 * 1000,
+      "4h": 4 * 60 * 60 * 1000,
+      "6h": 6 * 60 * 60 * 1000,
+      "1d": 24 * 60 * 60 * 1000,
+      "1w": 7 * 24 * 60 * 60 * 1000,
+      "1M": 30 * 24 * 60 * 60 * 1000,
+    };
+    return map[period] || 60 * 60 * 1000;
+  };
+
+  // --- 4. 核心:处理实时消息 (24hrTicker) ---
+  const handleSocketMessage = (msgStr) => {
+    try {
+      // 忽略心跳响应
+      if (msgStr === "pong") return;
+
+      const rawData = JSON.parse(msgStr);
+      const msg = rawData.data || rawData;
+      // console.log(rawData);
+      if (msg.e === "24hrTicker") {
+        marketInfo.value = {
+          price: msg.c,
+          change: parseFloat(msg.P),
+          high: msg.h,
+          low: msg.l,
+          vol: msg.v,
+          amount: msg.q,
+          fiatPrice: msg.c,
+        };
+
+        if (kLineData.value.length > 0) {
+          const lastIndex = kLineData.value.length - 1;
+          const lastBar = kLineData.value[lastIndex];
+          const newPrice = parseFloat(msg.c);
+          const currentTime = Number(msg.E); // 事件时间
+          const periodMs = getPeriodMs(currentTab.value);
+
+          // 标准时间戳对齐算法: (当前时间 / 周期) * 周期
+          const currentBarStart = Math.floor(currentTime / periodMs) * periodMs;
+
+          // 如果计算出的起始时间 > 最后一根的起始时间,说明跨周期了,生成新 K 线
+          if (currentBarStart > lastBar.timestamp) {
+            const newBar = {
+              timestamp: currentBarStart,
+              open: newPrice,
+              high: newPrice,
+              low: newPrice,
+              close: newPrice,
+              volume: 0,
+            };
+            // 扩展运算符触发更新
+            kLineData.value = [...kLineData.value, newBar];
+          } else {
+            // 还在当前周期内,更新最后一根
+            const updatedBar = {
+              ...lastBar,
+              close: newPrice,
+              high: Math.max(lastBar.high, newPrice),
+              low: Math.min(lastBar.low, newPrice),
+            };
+            kLineData.value.splice(lastIndex, 1, updatedBar);
+          }
+        }
+      } else if (rawData.stream == String(route.query.type) + "@depth20@1000ms") {
+        orderPlacement.value = rawData.data;
+      } else if (rawData.stream == String(route.query.type) + "@aggTrade") {
+        latestTransactionData.value = rawData.data;
+      }
+    } catch (e) {}
+  };
+
+  // --- 页面可见性监听 (切屏回来自动重连) ---
+  const handleVisibilityChange = () => {
+    if (document.visibilityState === "visible") {
+      if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
+        console.log("👀 Page Visible, reconnecting...");
+        connectWebSocket();
+      }
+    }
+  };
+
+  onMounted(() => {
+    isUnmounted = false;
+    getKlineData();
+    connectWebSocket();
+    document.addEventListener("visibilitychange", handleVisibilityChange);
+  });
+
+  onBeforeUnmount(() => {
+    isUnmounted = true;
+    closeWebSocket();
+    document.removeEventListener("visibilitychange", handleVisibilityChange);
+  });
+
+  watch(
+    symbolId,
+    () => {
+      kLineData.value = [];
+      getKlineData();
+      connectWebSocket();
+    },
+    { immediate: false }
+  );
+
+  const messageChange = (key) => {
+    current.value = key;
+  };
+  const formatNumber = (num) => {
+    if (!num) return "0.00";
+    const n = Number(num);
+    return n.toLocaleString("en-US", {
+      minimumFractionDigits: 2,
+      maximumFractionDigits: 6,
+    });
+  };
+  const abbreviateNumber = (value) => {
+    if (!value) return "0.00";
+    let num = parseFloat(value);
+    if (isNaN(num)) return "0.00";
+    if (num >= 1e9) return (num / 1e9).toFixed(2) + "B";
+    if (num >= 1e6) return (num / 1e6).toFixed(2) + "M";
+    if (num >= 1e3) return (num / 1e3).toFixed(2) + "K";
+    return num.toFixed(2);
+  };
+  const getPricePrecision = (price) => (price < 1 ? 6 : price < 10 ? 4 : 2);
+  const getPriceColor = (change) => (change >= 0 ? "fc45B26B" : "fcF6465D");
+  const getUpDownClass = (change) => (change >= 0 ? "fc45B26B" : "fcF6465D");
+</script>
+
+<style lang="less" scoped>
+  .fc45B26B {
+    color: #2ebd85 !important;
+  }
+  .fcF6465D {
+    color: #f6465d !important;
+  }
+  .fc1F2937 {
+    color: #1f2937;
+  }
+
+  /* 保持之前的样式布局 */
+  .time-tabs {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    box-sizing: border-box;
+    padding-top: 10px;
+    padding-bottom: 0px;
+    padding-left: 15px;
+    padding-right: 15px;
+  }
+
+  .tab-item {
+    font-size: 14px;
+    color: #929aa5;
+    padding: 4px 10px;
+    border-radius: 6px;
+    cursor: pointer;
+    font-weight: 500;
+    transition: all 0.2s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .tab-item.icon {
+    color: #929aa5;
+    font-size: 12px;
+    padding: 4px 4px;
+  }
+
+  .triangle {
+    font-size: 8px;
+    margin-left: 2px;
+    transform: scale(0.9);
+  }
+
+  .tab-item img {
+    display: block;
+    height: 16px;
+    width: auto;
+  }
+
+  .market-conditions {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    align-items: center;
+    margin-bottom: 50px;
+    width: 100%;
+
+    .market-price {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      margin-top: 8px;
+      width: 100%;
+      height: 73px;
+      padding: 0 15px;
+      box-sizing: border-box;
+
+      .price-left {
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        width: 144px;
+        height: 69px;
+
+        .left-price {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          height: 18px;
+
+          img {
+            margin-left: 5px;
+            width: 8px;
+            height: 4px;
+          }
+        }
+
+        .left-number {
+          margin-top: 5px;
+        }
+
+        .left-appro {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          margin-top: 3px;
+
+          .appro {
+            margin-left: 9px;
+          }
+        }
+      }
+
+      .price-right {
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        height: 100%;
+
+        .right-number-top,
+        .right-number-bottom {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-end;
+          width: 100%;
+          height: 32px;
+
+          .right-number-top-price,
+          .right-number-top-number {
+            margin-left: 10px;
+            text-align: right;
+            div {
+              height: 16px;
+              line-height: 16px;
+              text-align: end;
+            }
+          }
+        }
+        .right-number-bottom {
+          margin-top: 9px;
+        }
+      }
+    }
+
+    .k-line-main {
+      height: 50vh;
+      min-height: 350px;
+      width: 100%;
+      padding: 0 15px;
+    }
+
+    .notifi-classifi {
+      display: flex;
+      flex-direction: row;
+      justify-content: flex-start;
+      align-items: flex-end;
+      margin-top: 15px;
+      width: 100%;
+      padding: 0 15px;
+      box-sizing: border-box;
+      height: 24px;
+
+      .sys-notifi {
+        margin-left: 47px;
+      }
+    }
+  }
+</style>
+<template>
+  <div class="market-conditions">
+    <div class="market-price">
+      <div class="price-left">
+        <div class="left-price pf400 fs14 fc333333">实时价格</div>
+        <div
+          class="left-number pf600 fs20 fc1F2937"
+          :class="getPriceColor(marketInfo.change)">
+          {{ formatNumber(marketInfo.price) }}
+        </div>
+        <div class="left-appro pf500 fs14 fcA8A8A8">
+          ≈{{ formatNumber(marketInfo.fiatPrice) }}
+          <span class="appro pf500 fs14" :class="getUpDownClass(marketInfo.change)">
+            {{ marketInfo.change > 0 ? "+" : "" }}{{ marketInfo.change }}%
+          </span>
+        </div>
+      </div>
+
+      <div class="price-right">
+        <div class="right-number-top">
+          <div class="right-number-top-price">
+            <div class="pf400 fs10 fcA8A8A8">24h 最高价</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.high) }}</div>
+          </div>
+          <div class="right-number-top-number">
+            <div class="pf400 fs10 fcA8A8A8">24h 成交量</div>
+            <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.vol) }}</div>
+          </div>
+        </div>
+        <div class="right-number-bottom">
+          <div class="right-number-top-price">
+            <div class="pf400 fs10 fcA8A8A8">24h 最低价</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.low) }}</div>
+          </div>
+          <div class="right-number-top-number">
+            <div class="pf400 fs10 fcA8A8A8">24h 成交额</div>
+            <div class="pf400 fs10 fc2C3131">
+              {{ abbreviateNumber(marketInfo.amount) }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 周期切换 Tab -->
+    <nav class="time-tabs">
+      <div
+        v-for="tab in tabs"
+        :key="tab"
+        class="tab-item"
+        :style="currentTab === tab ? { backgroundColor: '#F6465D', color: '#fff' } : {}"
+        @click="switchPeriod(tab)">
+        {{ tab }}
+      </div>
+      <div class="tab-item icon">更多 <span class="triangle">◢</span></div>
+      <div class="tab-item icon">
+        <img src="@/assets/icon/bitcoin/lishidingdan.svg" alt="" />
+      </div>
+    </nav>
+
+    <div class="k-line-main">
+      <KLineChart
+        ref="klineRef"
+        :data="kLineData"
+        height="100%"
+        :precision="{ price: getPricePrecision(marketInfo.price), volume: 2 }" />
+    </div>
+
+    <div class="notifi-classifi">
+      <div
+        class="pf600 fs14"
+        :class="current === 'entrustingOrder' ? 'fc121212' : 'fcA8A8A8'"
+        @click="messageChange('entrustingOrder')">
+        委托挂单
+      </div>
+      <div
+        class="sys-notifi pf600 fs14"
+        :class="current === 'latestTransactions' ? 'fc121212' : 'fcA8A8A8'"
+        @click="messageChange('latestTransactions')">
+        最新成交
+      </div>
+    </div>
+
+    <component
+      :is="currentComponent"
+      :symbol-id="symbolId"
+      :latestTransactionData="latestTransactionData"
+      :orderPlacement="orderPlacement" />
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted, onUnmounted, watch, onBeforeUnmount } from "vue";
+  import { useRoute } from "vue-router";
+  import { GetCandlestickChart } from "@/api/index.js";
+  import EntrustingOrder from "./EntrustingOrder.vue";
+  import LatestTransactions from "./LatestTransactions.vue";
+  import KLineChart from "@/views/bitcoin/lever/components/KLineChart.vue";
+
+  const route = useRoute();
+  const symbolId = computed(() => route.query.id || "6");
+
+  const currentTab = ref("1d");
+  const tabs = ["1h", "6h", "1d", "1w", "1m"];
+  const kLineData = ref([]);
+  const socket = ref(null);
+  const WS_BASE_URL = "ws://backend.66linknow.com/ws/kline/";
+
+  // --- 生产级配置 ---
+  const HEARTBEAT_INTERVAL = 15000; // 心跳间隔 15s
+  const RECONNECT_DELAY = 3000; // 重连延迟 3s
+  let heartbeatTimer = null;
+  let reconnectTimer = null;
+  let isUnmounted = false;
+
+  const marketInfo = ref({
+    price: "0.00",
+    fiatPrice: "0.00",
+    change: 0.0,
+    high: "0.00",
+    low: "0.00",
+    vol: "0",
+    amount: "0",
+  });
+
+  const orderPlacement = ref();
+  const latestTransactionData = ref();
+
+  const current = ref("entrustingOrder");
+  const componentsMap = {
+    entrustingOrder: EntrustingOrder,
+    latestTransactions: LatestTransactions,
+  };
+  const currentComponent = computed(() => componentsMap[current.value]);
+
+  // --- 1. 切换周期 ---
+  const switchPeriod = (period) => {
+    if (currentTab.value === period) return;
+
+    currentTab.value = period;
+
+    // 清空数据,触发子组件重置,并重新请求
+    kLineData.value = [];
+    getKlineData();
+  };
+
+  // --- 2. HTTP 获取历史数据 ---
+  const getKlineData = async () => {
+    if (typeof GetCandlestickChart !== "function") return;
+
+    try {
+      const res = await GetCandlestickChart({
+        symbol: symbolId.value,
+        period: currentTab.value,
+      });
+
+      let rawList = [];
+      if (Array.isArray(res)) rawList = res;
+      else if (res && Array.isArray(res.data)) rawList = res.data;
+
+      if (rawList.length > 0) {
+        const formattedData = rawList.map((item) => ({
+          timestamp: Number(item[0]),
+          open: parseFloat(item[1]),
+          high: parseFloat(item[2]),
+          low: parseFloat(item[3]),
+          close: parseFloat(item[4]),
+          volume: parseFloat(item[5]),
+        }));
+        formattedData.sort((a, b) => a.timestamp - b.timestamp);
+        kLineData.value = formattedData;
+
+        // 同步合并:如果 WS 已经有最新价,立即修正历史数据最后一根,防止回跳
+        if (marketInfo.value.price !== "0.00") {
+          const lastBar = formattedData[formattedData.length - 1];
+          const realTimePrice = parseFloat(marketInfo.value.price);
+          lastBar.close = realTimePrice;
+          // 修正高低
+          if (realTimePrice > lastBar.high) lastBar.high = realTimePrice;
+          if (realTimePrice < lastBar.low) lastBar.low = realTimePrice;
+        }
+
+        updateMarketInfoFromKline(formattedData);
+      }
+    } catch (error) {
+      console.error("API Error", error);
+    }
+  };
+
+  const updateMarketInfoFromKline = (data) => {
+    if (!data.length) return;
+    const lastBar = data[data.length - 1];
+
+    // 仅当没数据或 WS 未连接时使用历史数据兜底
+    if (
+      marketInfo.value.price === "0.00" ||
+      !socket.value ||
+      socket.value.readyState !== 1
+    ) {
+      const firstBar = data[0];
+      let maxHigh = -Infinity,
+        minLow = Infinity,
+        totalVol = 0;
+      data.forEach((item) => {
+        if (item.high > maxHigh) maxHigh = item.high;
+        if (item.low < minLow) minLow = item.low;
+        totalVol += item.volume;
+      });
+      const changeRate = firstBar.open
+        ? ((lastBar.close - firstBar.open) / firstBar.open) * 100
+        : 0;
+
+      marketInfo.value = {
+        price: lastBar.close,
+        change: changeRate.toFixed(2),
+        high: maxHigh,
+        low: minLow,
+        vol: totalVol,
+        amount: totalVol * lastBar.close,
+        fiatPrice: lastBar.close,
+      };
+    }
+  };
+
+  // --- 3. WS 连接 (含心跳设计) ---
+  const connectWebSocket = () => {
+    // 清理旧资源
+    closeWebSocket();
+    const symbolStr = String(route.query.type || "btcusdt").toLowerCase();
+    const url = `${WS_BASE_URL}?symbol=${symbolStr}`;
+
+    console.log("WS 连接:", url);
+
+    try {
+      socket.value = new WebSocket(url);
+
+      socket.value.onopen = () => {
+        console.log("✅ WS Connected");
+        startHeartbeat(); // 启动心跳
+      };
+
+      socket.value.onmessage = (event) => {
+        handleSocketMessage(event.data);
+      };
+
+      socket.value.onclose = () => {
+        stopHeartbeat(); // 停止心跳
+        if (!isUnmounted) {
+          console.log("⚠️ WS Closed, reconnecting...");
+          clearTimeout(reconnectTimer);
+          reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
+        }
+      };
+
+      socket.value.onerror = (err) => {
+        // onerror 通常会触发 onclose,由 onclose 处理重连
+        console.error("❌ WS Error", err);
+      };
+    } catch (e) {
+      if (!isUnmounted) {
+        reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
+      }
+    }
+  };
+
+  const closeWebSocket = () => {
+    if (socket.value) {
+      socket.value.close();
+      socket.value = null;
+    }
+    stopHeartbeat();
+    clearTimeout(reconnectTimer);
+  };
+
+  // --- 心跳逻辑 ---
+  const startHeartbeat = () => {
+    stopHeartbeat();
+    heartbeatTimer = setInterval(() => {
+      if (socket.value && socket.value.readyState === WebSocket.OPEN) {
+        // 发送 ping,具体格式看后端要求,一般是字符串 'ping' 或 JSON
+        socket.value.send("ping");
+      }
+    }, HEARTBEAT_INTERVAL);
+  };
+
+  const stopHeartbeat = () => {
+    if (heartbeatTimer) {
+      clearInterval(heartbeatTimer);
+      heartbeatTimer = null;
+    }
+  };
+
+  // --- 辅助:获取周期对应的毫秒数 ---
+  const getPeriodMs = (period) => {
+    const map = {
+      "1m": 60 * 1000,
+      "5m": 5 * 60 * 1000,
+      "15m": 15 * 60 * 1000,
+      "30m": 30 * 60 * 1000,
+      "1h": 60 * 60 * 1000,
+      "4h": 4 * 60 * 60 * 1000,
+      "6h": 6 * 60 * 60 * 1000,
+      "1d": 24 * 60 * 60 * 1000,
+      "1w": 7 * 24 * 60 * 60 * 1000,
+      "1M": 30 * 24 * 60 * 60 * 1000,
+    };
+    return map[period] || 60 * 60 * 1000;
+  };
+
+  // --- 4. 核心:处理实时消息 (24hrTicker) ---
+  const handleSocketMessage = (msgStr) => {
+    try {
+      // 忽略心跳响应
+      if (msgStr === "pong") return;
+
+      const rawData = JSON.parse(msgStr);
+      const msg = rawData.data || rawData;
+      // console.log(rawData);
+      if (msg.e === "24hrTicker") {
+        marketInfo.value = {
+          price: msg.c,
+          change: parseFloat(msg.P),
+          high: msg.h,
+          low: msg.l,
+          vol: msg.v,
+          amount: msg.q,
+          fiatPrice: msg.c,
+        };
+
+        if (kLineData.value.length > 0) {
+          const lastIndex = kLineData.value.length - 1;
+          const lastBar = kLineData.value[lastIndex];
+          const newPrice = parseFloat(msg.c);
+          const currentTime = Number(msg.E); // 事件时间
+          const periodMs = getPeriodMs(currentTab.value);
+
+          // 标准时间戳对齐算法: (当前时间 / 周期) * 周期
+          const currentBarStart = Math.floor(currentTime / periodMs) * periodMs;
+
+          // 如果计算出的起始时间 > 最后一根的起始时间,说明跨周期了,生成新 K 线
+          if (currentBarStart > lastBar.timestamp) {
+            const newBar = {
+              timestamp: currentBarStart,
+              open: newPrice,
+              high: newPrice,
+              low: newPrice,
+              close: newPrice,
+              volume: 0,
+            };
+            // 扩展运算符触发更新
+            kLineData.value = [...kLineData.value, newBar];
+          } else {
+            // 还在当前周期内,更新最后一根
+            const updatedBar = {
+              ...lastBar,
+              close: newPrice,
+              high: Math.max(lastBar.high, newPrice),
+              low: Math.min(lastBar.low, newPrice),
+            };
+            kLineData.value.splice(lastIndex, 1, updatedBar);
+          }
+        }
+      } else if (rawData.stream == String(route.query.type) + "@depth20@1000ms") {
+        orderPlacement.value = rawData.data;
+      } else if (rawData.stream == String(route.query.type) + "@aggTrade") {
+        latestTransactionData.value = rawData.data;
+      }
+    } catch (e) {}
+  };
+
+  // --- 页面可见性监听 (切屏回来自动重连) ---
+  const handleVisibilityChange = () => {
+    if (document.visibilityState === "visible") {
+      if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
+        console.log("👀 Page Visible, reconnecting...");
+        connectWebSocket();
+      }
+    }
+  };
+
+  onMounted(() => {
+    isUnmounted = false;
+    getKlineData();
+    connectWebSocket();
+    document.addEventListener("visibilitychange", handleVisibilityChange);
+  });
+
+  onBeforeUnmount(() => {
+    isUnmounted = true;
+    closeWebSocket();
+    document.removeEventListener("visibilitychange", handleVisibilityChange);
+  });
+
+  watch(
+    symbolId,
+    () => {
+      kLineData.value = [];
+      getKlineData();
+      connectWebSocket();
+    },
+    { immediate: false }
+  );
+
+  const messageChange = (key) => {
+    current.value = key;
+  };
+  const formatNumber = (num) => {
+    if (!num) return "0.00";
+    const n = Number(num);
+    return n.toLocaleString("en-US", {
+      minimumFractionDigits: 2,
+      maximumFractionDigits: 6,
+    });
+  };
+  const abbreviateNumber = (value) => {
+    if (!value) return "0.00";
+    let num = parseFloat(value);
+    if (isNaN(num)) return "0.00";
+    if (num >= 1e9) return (num / 1e9).toFixed(2) + "B";
+    if (num >= 1e6) return (num / 1e6).toFixed(2) + "M";
+    if (num >= 1e3) return (num / 1e3).toFixed(2) + "K";
+    return num.toFixed(2);
+  };
+  const getPricePrecision = (price) => (price < 1 ? 6 : price < 10 ? 4 : 2);
+  const getPriceColor = (change) => (change >= 0 ? "fc45B26B" : "fcF6465D");
+  const getUpDownClass = (change) => (change >= 0 ? "fc45B26B" : "fcF6465D");
+</script>
+
+<style lang="less" scoped>
+  .fc45B26B {
+    color: #2ebd85 !important;
+  }
+  .fcF6465D {
+    color: #f6465d !important;
+  }
+  .fc1F2937 {
+    color: #1f2937;
+  }
+
+  /* 保持之前的样式布局 */
+  .time-tabs {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    box-sizing: border-box;
+    padding-top: 10px;
+    padding-bottom: 0px;
+    padding-left: 15px;
+    padding-right: 15px;
+  }
+
+  .tab-item {
+    font-size: 14px;
+    color: #929aa5;
+    padding: 4px 10px;
+    border-radius: 6px;
+    cursor: pointer;
+    font-weight: 500;
+    transition: all 0.2s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .tab-item.icon {
+    color: #929aa5;
+    font-size: 12px;
+    padding: 4px 4px;
+  }
+
+  .triangle {
+    font-size: 8px;
+    margin-left: 2px;
+    transform: scale(0.9);
+  }
+
+  .tab-item img {
+    display: block;
+    height: 16px;
+    width: auto;
+  }
+
+  .market-conditions {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    align-items: center;
+    margin-bottom: 50px;
+    width: 100%;
+
+    .market-price {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      margin-top: 8px;
+      width: 100%;
+      height: 73px;
+      padding: 0 15px;
+      box-sizing: border-box;
+
+      .price-left {
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        width: 144px;
+        height: 69px;
+
+        .left-price {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          height: 18px;
+
+          img {
+            margin-left: 5px;
+            width: 8px;
+            height: 4px;
+          }
+        }
+
+        .left-number {
+          margin-top: 5px;
+        }
+
+        .left-appro {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          margin-top: 3px;
+
+          .appro {
+            margin-left: 9px;
+          }
+        }
+      }
+
+      .price-right {
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        height: 100%;
+
+        .right-number-top,
+        .right-number-bottom {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-end;
+          width: 100%;
+          height: 32px;
+
+          .right-number-top-price,
+          .right-number-top-number {
+            margin-left: 10px;
+            text-align: right;
+            div {
+              height: 16px;
+              line-height: 16px;
+              text-align: end;
+            }
+          }
+        }
+        .right-number-bottom {
+          margin-top: 9px;
+        }
+      }
+    }
+
+    .k-line-main {
+      height: 50vh;
+      min-height: 350px;
+      width: 100%;
+      padding: 0 15px;
+    }
+
+    .notifi-classifi {
+      display: flex;
+      flex-direction: row;
+      justify-content: flex-start;
+      align-items: flex-end;
+      margin-top: 15px;
+      width: 100%;
+      padding: 0 15px;
+      box-sizing: border-box;
+      height: 24px;
+
+      .sys-notifi {
+        margin-left: 47px;
+      }
+    }
+  }
+</style>
+<template>
+  <div class="market-conditions">
+    <div class="market-price">
+      <div class="price-left">
+        <div class="left-price pf400 fs14 fc333333">实时价格</div>
+        <div
+          class="left-number pf600 fs20 fc1F2937"
+          :class="getPriceColor(marketInfo.change)">
+          {{ formatNumber(marketInfo.price) }}
+        </div>
+        <div class="left-appro pf500 fs14 fcA8A8A8">
+          ≈{{ formatNumber(marketInfo.fiatPrice) }}
+          <span class="appro pf500 fs14" :class="getUpDownClass(marketInfo.change)">
+            {{ marketInfo.change > 0 ? "+" : "" }}{{ marketInfo.change }}%
+          </span>
+        </div>
+      </div>
+
+      <div class="price-right">
+        <div class="right-number-top">
+          <div class="right-number-top-price">
+            <div class="pf400 fs10 fcA8A8A8">24h 最高价</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.high) }}</div>
+          </div>
+          <div class="right-number-top-number">
+            <div class="pf400 fs10 fcA8A8A8">24h 成交量</div>
+            <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.vol) }}</div>
+          </div>
+        </div>
+        <div class="right-number-bottom">
+          <div class="right-number-top-price">
+            <div class="pf400 fs10 fcA8A8A8">24h 最低价</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.low) }}</div>
+          </div>
+          <div class="right-number-top-number">
+            <div class="pf400 fs10 fcA8A8A8">24h 成交额</div>
+            <div class="pf400 fs10 fc2C3131">
+              {{ abbreviateNumber(marketInfo.amount) }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 周期切换 Tab -->
+    <nav class="time-tabs">
+      <div
+        v-for="tab in tabs"
+        :key="tab"
+        class="tab-item"
+        :style="currentTab === tab ? { backgroundColor: '#F6465D', color: '#fff' } : {}"
+        @click="switchPeriod(tab)">
+        {{ tab }}
+      </div>
+      <div class="tab-item icon">更多 <span class="triangle">◢</span></div>
+      <div class="tab-item icon">
+        <img src="@/assets/icon/bitcoin/lishidingdan.svg" alt="" />
+      </div>
+    </nav>
+
+    <div class="k-line-main">
+      <KlineChart
+        ref="klineRef"
+        :data="kLineData"
+        height="100%"
+        :precision="{ price: getPricePrecision(marketInfo.price), volume: 2 }" />
+    </div>
+
+    <div class="notifi-classifi">
+      <div
+        class="pf600 fs14"
+        :class="current === 'entrustingOrder' ? 'fc121212' : 'fcA8A8A8'"
+        @click="messageChange('entrustingOrder')">
+        委托挂单
+      </div>
+      <div
+        class="sys-notifi pf600 fs14"
+        :class="current === 'latestTransactions' ? 'fc121212' : 'fcA8A8A8'"
+        @click="messageChange('latestTransactions')">
+        最新成交
+      </div>
+    </div>
+
+    <component
+      :is="currentComponent"
+      :symbol-id="symbolId"
+      :latestTransactionData="latestTransactionData"
+      :orderPlacement="orderPlacement" />
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted, onUnmounted, watch, onBeforeUnmount } from "vue";
+  import { useRoute } from "vue-router";
+  import { GetCandlestickChart } from "@/api/index.js";
+  import EntrustingOrder from "./EntrustingOrder.vue";
+  import LatestTransactions from "./LatestTransactions.vue";
+  import KlineChart from "@/views/bitcoin/lever/components/KLineChart.vue";
+
+  const route = useRoute();
+  const symbolId = computed(() => route.query.id || "6");
+
+  const currentTab = ref("1d");
+  const tabs = ["1h", "6h", "1d", "1w", "1m"];
+  const kLineData = ref([]);
+  const socket = ref(null);
+  const WS_BASE_URL = "ws://backend.66linknow.com/ws/kline/";
+
+  // --- 生产级配置 ---
+  const HEARTBEAT_INTERVAL = 15000; // 心跳间隔 15s
+  const RECONNECT_DELAY = 3000; // 重连延迟 3s
+  let heartbeatTimer = null;
+  let reconnectTimer = null;
+  let isUnmounted = false;
+
+  const marketInfo = ref({
+    price: "0.00",
+    fiatPrice: "0.00",
+    change: 0.0,
+    high: "0.00",
+    low: "0.00",
+    vol: "0",
+    amount: "0",
+  });
+
+  const orderPlacement = ref();
+  const latestTransactionData = ref();
+
+  const current = ref("entrustingOrder");
+  const componentsMap = {
+    entrustingOrder: EntrustingOrder,
+    latestTransactions: LatestTransactions,
+  };
+  const currentComponent = computed(() => componentsMap[current.value]);
+
+  // --- 1. 切换周期 ---
+  const switchPeriod = (period) => {
+    if (currentTab.value === period) return;
+
+    currentTab.value = period;
+
+    // 清空数据,触发子组件重置,并重新请求
+    kLineData.value = [];
+    getKlineData();
+  };
+
+  // --- 2. HTTP 获取历史数据 ---
+  const getKlineData = async () => {
+    if (typeof GetCandlestickChart !== "function") return;
+
+    try {
+      const res = await GetCandlestickChart({
+        symbol: symbolId.value,
+        period: currentTab.value,
+      });
+
+      let rawList = [];
+      if (Array.isArray(res)) rawList = res;
+      else if (res && Array.isArray(res.data)) rawList = res.data;
+
+      if (rawList.length > 0) {
+        const formattedData = rawList.map((item) => ({
+          timestamp: Number(item[0]),
+          open: parseFloat(item[1]),
+          high: parseFloat(item[2]),
+          low: parseFloat(item[3]),
+          close: parseFloat(item[4]),
+          volume: parseFloat(item[5]),
+        }));
+        formattedData.sort((a, b) => a.timestamp - b.timestamp);
+        kLineData.value = formattedData;
+
+        // 同步合并:如果 WS 已经有最新价,立即修正历史数据最后一根,防止回跳
+        if (marketInfo.value.price !== "0.00") {
+          const lastBar = formattedData[formattedData.length - 1];
+          const realTimePrice = parseFloat(marketInfo.value.price);
+          lastBar.close = realTimePrice;
+          // 修正高低
+          if (realTimePrice > lastBar.high) lastBar.high = realTimePrice;
+          if (realTimePrice < lastBar.low) lastBar.low = realTimePrice;
+        }
+
+        updateMarketInfoFromKline(formattedData);
+      }
+    } catch (error) {
+      console.error("API Error", error);
+    }
+  };
+
+  const updateMarketInfoFromKline = (data) => {
+    if (!data.length) return;
+    const lastBar = data[data.length - 1];
+
+    // 仅当没数据或 WS 未连接时使用历史数据兜底
+    if (
+      marketInfo.value.price === "0.00" ||
+      !socket.value ||
+      socket.value.readyState !== 1
+    ) {
+      const firstBar = data[0];
+      let maxHigh = -Infinity,
+        minLow = Infinity,
+        totalVol = 0;
+      data.forEach((item) => {
+        if (item.high > maxHigh) maxHigh = item.high;
+        if (item.low < minLow) minLow = item.low;
+        totalVol += item.volume;
+      });
+      const changeRate = firstBar.open
+        ? ((lastBar.close - firstBar.open) / firstBar.open) * 100
+        : 0;
+
+      marketInfo.value = {
+        price: lastBar.close,
+        change: changeRate.toFixed(2),
+        high: maxHigh,
+        low: minLow,
+        vol: totalVol,
+        amount: totalVol * lastBar.close,
+        fiatPrice: lastBar.close,
+      };
+    }
+  };
+
+  // --- 3. WS 连接 (含心跳设计) ---
+  const connectWebSocket = () => {
+    // 清理旧资源
+    closeWebSocket();
+    const symbolStr = String(route.query.type || "btcusdt").toLowerCase();
+    const url = `${WS_BASE_URL}?symbol=${symbolStr}`;
+
+    console.log("WS 连接:", url);
+
+    try {
+      socket.value = new WebSocket(url);
+
+      socket.value.onopen = () => {
+        console.log("✅ WS Connected");
+        startHeartbeat(); // 启动心跳
+      };
+
+      socket.value.onmessage = (event) => {
+        handleSocketMessage(event.data);
+      };
+
+      socket.value.onclose = () => {
+        stopHeartbeat(); // 停止心跳
+        if (!isUnmounted) {
+          console.log("⚠️ WS Closed, reconnecting...");
+          clearTimeout(reconnectTimer);
+          reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
+        }
+      };
+
+      socket.value.onerror = (err) => {
+        // onerror 通常会触发 onclose,由 onclose 处理重连
+        console.error("❌ WS Error", err);
+      };
+    } catch (e) {
+      if (!isUnmounted) {
+        reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
+      }
+    }
+  };
+
+  const closeWebSocket = () => {
+    if (socket.value) {
+      socket.value.close();
+      socket.value = null;
+    }
+    stopHeartbeat();
+    clearTimeout(reconnectTimer);
+  };
+
+  // --- 心跳逻辑 ---
+  const startHeartbeat = () => {
+    stopHeartbeat();
+    heartbeatTimer = setInterval(() => {
+      if (socket.value && socket.value.readyState === WebSocket.OPEN) {
+        // 发送 ping,具体格式看后端要求,一般是字符串 'ping' 或 JSON
+        socket.value.send("ping");
+      }
+    }, HEARTBEAT_INTERVAL);
+  };
+
+  const stopHeartbeat = () => {
+    if (heartbeatTimer) {
+      clearInterval(heartbeatTimer);
+      heartbeatTimer = null;
+    }
+  };
+
+  // --- 辅助:获取周期对应的毫秒数 ---
+  const getPeriodMs = (period) => {
+    const map = {
+      "1m": 60 * 1000,
+      "5m": 5 * 60 * 1000,
+      "15m": 15 * 60 * 1000,
+      "30m": 30 * 60 * 1000,
+      "1h": 60 * 60 * 1000,
+      "4h": 4 * 60 * 60 * 1000,
+      "6h": 6 * 60 * 60 * 1000,
+      "1d": 24 * 60 * 60 * 1000,
+      "1w": 7 * 24 * 60 * 60 * 1000,
+      "1M": 30 * 24 * 60 * 60 * 1000,
+    };
+    return map[period] || 60 * 60 * 1000;
+  };
+
+  // --- 4. 核心:处理实时消息 (24hrTicker) ---
+  const handleSocketMessage = (msgStr) => {
+    try {
+      // 忽略心跳响应
+      if (msgStr === "pong") return;
+
+      const rawData = JSON.parse(msgStr);
+      const msg = rawData.data || rawData;
+      // console.log(rawData);
+      if (msg.e === "24hrTicker") {
+        marketInfo.value = {
+          price: msg.c,
+          change: parseFloat(msg.P),
+          high: msg.h,
+          low: msg.l,
+          vol: msg.v,
+          amount: msg.q,
+          fiatPrice: msg.c,
+        };
+
+        if (kLineData.value.length > 0) {
+          const lastIndex = kLineData.value.length - 1;
+          const lastBar = kLineData.value[lastIndex];
+          const newPrice = parseFloat(msg.c);
+          const currentTime = Number(msg.E); // 事件时间
+          const periodMs = getPeriodMs(currentTab.value);
+
+          // 标准时间戳对齐算法: (当前时间 / 周期) * 周期
+          const currentBarStart = Math.floor(currentTime / periodMs) * periodMs;
+
+          // 如果计算出的起始时间 > 最后一根的起始时间,说明跨周期了,生成新 K 线
+          if (currentBarStart > lastBar.timestamp) {
+            const newBar = {
+              timestamp: currentBarStart,
+              open: newPrice,
+              high: newPrice,
+              low: newPrice,
+              close: newPrice,
+              volume: 0,
+            };
+            // 扩展运算符触发更新
+            kLineData.value = [...kLineData.value, newBar];
+          } else {
+            // 还在当前周期内,更新最后一根
+            const updatedBar = {
+              ...lastBar,
+              close: newPrice,
+              high: Math.max(lastBar.high, newPrice),
+              low: Math.min(lastBar.low, newPrice),
+            };
+            kLineData.value.splice(lastIndex, 1, updatedBar);
+          }
+        }
+      } else if (rawData.stream == String(route.query.type) + "@depth20@1000ms") {
+        orderPlacement.value = rawData.data;
+      } else if (rawData.stream == String(route.query.type) + "@aggTrade") {
+        latestTransactionData.value = rawData.data;
+      }
+    } catch (e) {}
+  };
+
+  // --- 页面可见性监听 (切屏回来自动重连) ---
+  const handleVisibilityChange = () => {
+    if (document.visibilityState === "visible") {
+      if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
+        console.log("👀 Page Visible, reconnecting...");
+        connectWebSocket();
+      }
+    }
+  };
+
+  onMounted(() => {
+    isUnmounted = false;
+    getKlineData();
+    connectWebSocket();
+    document.addEventListener("visibilitychange", handleVisibilityChange);
+  });
+
+  onBeforeUnmount(() => {
+    isUnmounted = true;
+    closeWebSocket();
+    document.removeEventListener("visibilitychange", handleVisibilityChange);
+  });
+
+  watch(
+    symbolId,
+    () => {
+      kLineData.value = [];
+      getKlineData();
+      connectWebSocket();
+    },
+    { immediate: false }
+  );
+
+  const messageChange = (key) => {
+    current.value = key;
+  };
+  const formatNumber = (num) => {
+    if (!num) return "0.00";
+    const n = Number(num);
+    return n.toLocaleString("en-US", {
+      minimumFractionDigits: 2,
+      maximumFractionDigits: 6,
+    });
+  };
+  const abbreviateNumber = (value) => {
+    if (!value) return "0.00";
+    let num = parseFloat(value);
+    if (isNaN(num)) return "0.00";
+    if (num >= 1e9) return (num / 1e9).toFixed(2) + "B";
+    if (num >= 1e6) return (num / 1e6).toFixed(2) + "M";
+    if (num >= 1e3) return (num / 1e3).toFixed(2) + "K";
+    return num.toFixed(2);
+  };
+  const getPricePrecision = (price) => (price < 1 ? 6 : price < 10 ? 4 : 2);
+  const getPriceColor = (change) => (change >= 0 ? "fc45B26B" : "fcF6465D");
+  const getUpDownClass = (change) => (change >= 0 ? "fc45B26B" : "fcF6465D");
+</script>
+
+<style lang="less" scoped>
+  .fc45B26B {
+    color: #2ebd85 !important;
+  }
+  .fcF6465D {
+    color: #f6465d !important;
+  }
+  .fc1F2937 {
+    color: #1f2937;
+  }
+
+  /* 保持之前的样式布局 */
+  .time-tabs {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    box-sizing: border-box;
+    padding-top: 10px;
+    padding-bottom: 0px;
+    padding-left: 15px;
+    padding-right: 15px;
+  }
+
+  .tab-item {
+    font-size: 14px;
+    color: #929aa5;
+    padding: 4px 10px;
+    border-radius: 6px;
+    cursor: pointer;
+    font-weight: 500;
+    transition: all 0.2s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .tab-item.icon {
+    color: #929aa5;
+    font-size: 12px;
+    padding: 4px 4px;
+  }
+
+  .triangle {
+    font-size: 8px;
+    margin-left: 2px;
+    transform: scale(0.9);
+  }
+
+  .tab-item img {
+    display: block;
+    height: 16px;
+    width: auto;
+  }
+
+  .market-conditions {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    align-items: center;
+    margin-bottom: 50px;
+    width: 100%;
+
+    .market-price {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      margin-top: 8px;
+      width: 100%;
+      height: 73px;
+      padding: 0 15px;
+      box-sizing: border-box;
+
+      .price-left {
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        width: 144px;
+        height: 69px;
+
+        .left-price {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          height: 18px;
+
+          img {
+            margin-left: 5px;
+            width: 8px;
+            height: 4px;
+          }
+        }
+
+        .left-number {
+          margin-top: 5px;
+        }
+
+        .left-appro {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          margin-top: 3px;
+
+          .appro {
+            margin-left: 9px;
+          }
+        }
+      }
+
+      .price-right {
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        height: 100%;
+
+        .right-number-top,
+        .right-number-bottom {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-end;
+          width: 100%;
+          height: 32px;
+
+          .right-number-top-price,
+          .right-number-top-number {
+            margin-left: 10px;
+            text-align: right;
+            div {
+              height: 16px;
+              line-height: 16px;
+              text-align: end;
+            }
+          }
+        }
+        .right-number-bottom {
+          margin-top: 9px;
+        }
+      }
+    }
+
+    .k-line-main {
+      height: 50vh;
+      min-height: 350px;
+      width: 100%;
+      padding: 0 15px;
+    }
+
+    .notifi-classifi {
+      display: flex;
+      flex-direction: row;
+      justify-content: flex-start;
+      align-items: flex-end;
+      margin-top: 15px;
+      width: 100%;
+      padding: 0 15px;
+      box-sizing: border-box;
+      height: 24px;
+
+      .sys-notifi {
+        margin-left: 47px;
+      }
+    }
+  }
+</style>
+<template>
+  <div class="market-conditions">
+    <div class="market-price">
+      <div class="price-left">
+        <div class="left-price pf400 fs14 fc333333">实时价格</div>
+        <div
+          class="left-number pf600 fs20 fc1F2937"
+          :class="getPriceColor(marketInfo.change)">
+          {{ formatNumber(marketInfo.price) }}
+        </div>
+        <div class="left-appro pf500 fs14 fcA8A8A8">
+          ≈{{ formatNumber(marketInfo.fiatPrice) }}
+          <span class="appro pf500 fs14" :class="getUpDownClass(marketInfo.change)">
+            {{ marketInfo.change > 0 ? "+" : "" }}{{ marketInfo.change }}%
+          </span>
+        </div>
+      </div>
+
+      <div class="price-right">
+        <div class="right-number-top">
+          <div class="right-number-top-price">
+            <div class="pf400 fs10 fcA8A8A8">24h 最高价</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.high) }}</div>
+          </div>
+          <div class="right-number-top-number">
+            <div class="pf400 fs10 fcA8A8A8">24h 成交量</div>
+            <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.vol) }}</div>
+          </div>
+        </div>
+        <div class="right-number-bottom">
+          <div class="right-number-top-price">
+            <div class="pf400 fs10 fcA8A8A8">24h 最低价</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.low) }}</div>
+          </div>
+          <div class="right-number-top-number">
+            <div class="pf400 fs10 fcA8A8A8">24h 成交额</div>
+            <div class="pf400 fs10 fc2C3131">
+              {{ abbreviateNumber(marketInfo.amount) }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 周期切换 Tab -->
+    <nav class="time-tabs">
+      <div
+        v-for="tab in tabs"
+        :key="tab"
+        class="tab-item"
+        :style="currentTab === tab ? { backgroundColor: '#F6465D', color: '#fff' } : {}"
+        @click="switchPeriod(tab)">
+        {{ tab }}
+      </div>
+      <div class="tab-item icon">更多 <span class="triangle">◢</span></div>
+      <div class="tab-item icon">
+        <img src="@/assets/icon/bitcoin/lishidingdan.svg" alt="" />
+      </div>
+    </nav>
+
+    <div class="k-line-main">
+      <KlineChart
+        ref="klineRef"
+        :data="kLineData"
+        height="100%"
+        :precision="{ price: getPricePrecision(marketInfo.price), volume: 2 }" />
+    </div>
+
+    <div class="notifi-classifi">
+      <div
+        class="pf600 fs14"
+        :class="current === 'entrustingOrder' ? 'fc121212' : 'fcA8A8A8'"
+        @click="messageChange('entrustingOrder')">
+        委托挂单
+      </div>
+      <div
+        class="sys-notifi pf600 fs14"
+        :class="current === 'latestTransactions' ? 'fc121212' : 'fcA8A8A8'"
+        @click="messageChange('latestTransactions')">
+        最新成交
+      </div>
+    </div>
+
+    <component
+      :is="currentComponent"
+      :symbol-id="symbolId"
+      :latestTransactionData="latestTransactionData"
+      :orderPlacement="orderPlacement" />
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed, onMounted, onUnmounted, watch, onBeforeUnmount } from "vue";
+  import { useRoute } from "vue-router";
+  import { GetCandlestickChart } from "@/api/index.js";
+  import EntrustingOrder from "./EntrustingOrder.vue";
+  import LatestTransactions from "./LatestTransactions.vue";
+  import KlineChart from "@/views/bitcoin/lever/components/KLineChart.vue";
+
+  const route = useRoute();
+  const symbolId = computed(() => route.query.id || "6");
+
+  const currentTab = ref("1d");
+  const tabs = ["1h", "6h", "1d", "1w", "1m"];
+  const kLineData = ref([]);
+  const socket = ref(null);
+  const WS_BASE_URL = "ws://backend.66linknow.com/ws/kline/";
+
+  // --- 生产级配置 ---
+  const HEARTBEAT_INTERVAL = 15000; // 心跳间隔 15s
+  const RECONNECT_DELAY = 3000; // 重连延迟 3s
+  let heartbeatTimer = null;
+  let reconnectTimer = null;
+  let isUnmounted = false;
+
+  const marketInfo = ref({
+    price: "0.00",
+    fiatPrice: "0.00",
+    change: 0.0,
+    high: "0.00",
+    low: "0.00",
+    vol: "0",
+    amount: "0",
+  });
+
+  const orderPlacement = ref();
+  const latestTransactionData = ref();
+
+  const current = ref("entrustingOrder");
+  const componentsMap = {
+    entrustingOrder: EntrustingOrder,
+    latestTransactions: LatestTransactions,
+  };
+  const currentComponent = computed(() => componentsMap[current.value]);
+
+  // --- 1. 切换周期 ---
+  const switchPeriod = (period) => {
+    if (currentTab.value === period) return;
+
+    currentTab.value = period;
+
+    // 清空数据,触发子组件重置,并重新请求
+    kLineData.value = [];
+    getKlineData();
+  };
+
+  // --- 2. HTTP 获取历史数据 ---
+  const getKlineData = async () => {
+    if (typeof GetCandlestickChart !== "function") return;
+
+    try {
+      const res = await GetCandlestickChart({
+        symbol: symbolId.value,
+        period: currentTab.value,
+      });
+
+      let rawList = [];
+      if (Array.isArray(res)) rawList = res;
+      else if (res && Array.isArray(res.data)) rawList = res.data;
+
+      if (rawList.length > 0) {
+        const formattedData = rawList.map((item) => ({
+          timestamp: Number(item[0]),
+          open: parseFloat(item[1]),
+          high: parseFloat(item[2]),
+          low: parseFloat(item[3]),
+          close: parseFloat(item[4]),
+          volume: parseFloat(item[5]),
+        }));
+        formattedData.sort((a, b) => a.timestamp - b.timestamp);
+        kLineData.value = formattedData;
+
+        // 同步合并:如果 WS 已经有最新价,立即修正历史数据最后一根,防止回跳
+        if (marketInfo.value.price !== "0.00") {
+          const lastBar = formattedData[formattedData.length - 1];
+          const realTimePrice = parseFloat(marketInfo.value.price);
+          lastBar.close = realTimePrice;
+          // 修正高低
+          if (realTimePrice > lastBar.high) lastBar.high = realTimePrice;
+          if (realTimePrice < lastBar.low) lastBar.low = realTimePrice;
+        }
+
+        updateMarketInfoFromKline(formattedData);
+      }
+    } catch (error) {
+      console.error("API Error", error);
+    }
+  };
+
+  const updateMarketInfoFromKline = (data) => {
+    if (!data.length) return;
+    const lastBar = data[data.length - 1];
+
+    // 仅当没数据或 WS 未连接时使用历史数据兜底
+    if (
+      marketInfo.value.price === "0.00" ||
+      !socket.value ||
+      socket.value.readyState !== 1
+    ) {
+      const firstBar = data[0];
+      let maxHigh = -Infinity,
+        minLow = Infinity,
+        totalVol = 0;
+      data.forEach((item) => {
+        if (item.high > maxHigh) maxHigh = item.high;
+        if (item.low < minLow) minLow = item.low;
+        totalVol += item.volume;
+      });
+      const changeRate = firstBar.open
+        ? ((lastBar.close - firstBar.open) / firstBar.open) * 100
+        : 0;
+
+      marketInfo.value = {
+        price: lastBar.close,
+        change: changeRate.toFixed(2),
+        high: maxHigh,
+        low: minLow,
+        vol: totalVol,
+        amount: totalVol * lastBar.close,
+        fiatPrice: lastBar.close,
+      };
+    }
+  };
+
+  // --- 3. WS 连接 (含心跳设计) ---
+  const connectWebSocket = () => {
+    // 清理旧资源
+    closeWebSocket();
+    const symbolStr = String(route.query.type || "btcusdt").toLowerCase();
+    const url = `${WS_BASE_URL}?symbol=${symbolStr}`;
+
+    console.log("WS 连接:", url);
+
+    try {
+      socket.value = new WebSocket(url);
+
+      socket.value.onopen = () => {
+        console.log("✅ WS Connected");
+        startHeartbeat(); // 启动心跳
+      };
+
+      socket.value.onmessage = (event) => {
+        handleSocketMessage(event.data);
+      };
+
+      socket.value.onclose = () => {
+        stopHeartbeat(); // 停止心跳
+        if (!isUnmounted) {
+          console.log("⚠️ WS Closed, reconnecting...");
+          clearTimeout(reconnectTimer);
+          reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
+        }
+      };
+
+      socket.value.onerror = (err) => {
+        // onerror 通常会触发 onclose,由 onclose 处理重连
+        console.error("❌ WS Error", err);
+      };
+    } catch (e) {
+      if (!isUnmounted) {
+        reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
+      }
+    }
+  };
+
+  const closeWebSocket = () => {
+    if (socket.value) {
+      socket.value.close();
+      socket.value = null;
+    }
+    stopHeartbeat();
+    clearTimeout(reconnectTimer);
+  };
+
+  // --- 心跳逻辑 ---
+  const startHeartbeat = () => {
+    stopHeartbeat();
+    heartbeatTimer = setInterval(() => {
+      if (socket.value && socket.value.readyState === WebSocket.OPEN) {
+        // 发送 ping,具体格式看后端要求,一般是字符串 'ping' 或 JSON
+        socket.value.send("ping");
+      }
+    }, HEARTBEAT_INTERVAL);
+  };
+
+  const stopHeartbeat = () => {
+    if (heartbeatTimer) {
+      clearInterval(heartbeatTimer);
+      heartbeatTimer = null;
+    }
+  };
+
+  // --- 辅助:获取周期对应的毫秒数 ---
+  const getPeriodMs = (period) => {
+    const map = {
+      "1m": 60 * 1000,
+      "5m": 5 * 60 * 1000,
+      "15m": 15 * 60 * 1000,
+      "30m": 30 * 60 * 1000,
+      "1h": 60 * 60 * 1000,
+      "4h": 4 * 60 * 60 * 1000,
+      "6h": 6 * 60 * 60 * 1000,
+      "1d": 24 * 60 * 60 * 1000,
+      "1w": 7 * 24 * 60 * 60 * 1000,
+      "1M": 30 * 24 * 60 * 60 * 1000,
+    };
+    return map[period] || 60 * 60 * 1000;
+  };
+
+  // --- 4. 核心:处理实时消息 (24hrTicker) ---
+  const handleSocketMessage = (msgStr) => {
+    try {
+      // 忽略心跳响应
+      if (msgStr === "pong") return;
+
+      const rawData = JSON.parse(msgStr);
+      const msg = rawData.data || rawData;
+      // console.log(rawData);
+      if (msg.e === "24hrTicker") {
+        marketInfo.value = {
+          price: msg.c,
+          change: parseFloat(msg.P),
+          high: msg.h,
+          low: msg.l,
+          vol: msg.v,
+          amount: msg.q,
+          fiatPrice: msg.c,
+        };
+
+        if (kLineData.value.length > 0) {
+          const lastIndex = kLineData.value.length - 1;
+          const lastBar = kLineData.value[lastIndex];
+          const newPrice = parseFloat(msg.c);
+          const currentTime = Number(msg.E); // 事件时间
+          const periodMs = getPeriodMs(currentTab.value);
+
+          // 标准时间戳对齐算法: (当前时间 / 周期) * 周期
+          const currentBarStart = Math.floor(currentTime / periodMs) * periodMs;
+
+          // 如果计算出的起始时间 > 最后一根的起始时间,说明跨周期了,生成新 K 线
+          if (currentBarStart > lastBar.timestamp) {
+            const newBar = {
+              timestamp: currentBarStart,
+              open: newPrice,
+              high: newPrice,
+              low: newPrice,
+              close: newPrice,
+              volume: 0,
+            };
+            // 扩展运算符触发更新
+            kLineData.value = [...kLineData.value, newBar];
+          } else {
+            // 还在当前周期内,更新最后一根
+            const updatedBar = {
+              ...lastBar,
+              close: newPrice,
+              high: Math.max(lastBar.high, newPrice),
+              low: Math.min(lastBar.low, newPrice),
+            };
+            kLineData.value.splice(lastIndex, 1, updatedBar);
+          }
+        }
+      } else if (rawData.stream == String(route.query.type) + "@depth20@1000ms") {
+        orderPlacement.value = rawData.data;
+      } else if (rawData.stream == String(route.query.type) + "@aggTrade") {
+        latestTransactionData.value = rawData.data;
+      }
+    } catch (e) {}
+  };
+
+  // --- 页面可见性监听 (切屏回来自动重连) ---
+  const handleVisibilityChange = () => {
+    if (document.visibilityState === "visible") {
+      if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
+        console.log("👀 Page Visible, reconnecting...");
+        connectWebSocket();
+      }
+    }
+  };
+
+  onMounted(() => {
+    isUnmounted = false;
+    getKlineData();
+    connectWebSocket();
+    document.addEventListener("visibilitychange", handleVisibilityChange);
+  });
+
+  onBeforeUnmount(() => {
+    isUnmounted = true;
+    closeWebSocket();
+    document.removeEventListener("visibilitychange", handleVisibilityChange);
+  });
+
+  watch(
+    symbolId,
+    () => {
+      kLineData.value = [];
+      getKlineData();
+      connectWebSocket();
+    },
+    { immediate: false }
+  );
+
+  const messageChange = (key) => {
+    current.value = key;
+  };
+  const formatNumber = (num) => {
+    if (!num) return "0.00";
+    const n = Number(num);
+    return n.toLocaleString("en-US", {
+      minimumFractionDigits: 2,
+      maximumFractionDigits: 6,
+    });
+  };
+  const abbreviateNumber = (value) => {
+    if (!value) return "0.00";
+    let num = parseFloat(value);
+    if (isNaN(num)) return "0.00";
+    if (num >= 1e9) return (num / 1e9).toFixed(2) + "B";
+    if (num >= 1e6) return (num / 1e6).toFixed(2) + "M";
+    if (num >= 1e3) return (num / 1e3).toFixed(2) + "K";
+    return num.toFixed(2);
+  };
+  const getPricePrecision = (price) => (price < 1 ? 6 : price < 10 ? 4 : 2);
+  const getPriceColor = (change) => (change >= 0 ? "fc45B26B" : "fcF6465D");
+  const getUpDownClass = (change) => (change >= 0 ? "fc45B26B" : "fcF6465D");
+</script>
+
+<style lang="less" scoped>
+  .fc45B26B {
+    color: #2ebd85 !important;
+  }
+  .fcF6465D {
+    color: #f6465d !important;
+  }
+  .fc1F2937 {
+    color: #1f2937;
+  }
+
+  /* 保持之前的样式布局 */
+  .time-tabs {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    box-sizing: border-box;
+    padding-top: 10px;
+    padding-bottom: 0px;
+    padding-left: 15px;
+    padding-right: 15px;
+  }
+
+  .tab-item {
+    font-size: 14px;
+    color: #929aa5;
+    padding: 4px 10px;
+    border-radius: 6px;
+    cursor: pointer;
+    font-weight: 500;
+    transition: all 0.2s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .tab-item.icon {
+    color: #929aa5;
+    font-size: 12px;
+    padding: 4px 4px;
+  }
+
+  .triangle {
+    font-size: 8px;
+    margin-left: 2px;
+    transform: scale(0.9);
+  }
+
+  .tab-item img {
+    display: block;
+    height: 16px;
+    width: auto;
+  }
+
+  .market-conditions {
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-start;
+    align-items: center;
+    margin-bottom: 50px;
+    width: 100%;
+
+    .market-price {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      margin-top: 8px;
+      width: 100%;
+      height: 73px;
+      padding: 0 15px;
+      box-sizing: border-box;
+
+      .price-left {
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        width: 144px;
+        height: 69px;
+
+        .left-price {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          height: 18px;
+
+          img {
+            margin-left: 5px;
+            width: 8px;
+            height: 4px;
+          }
+        }
+
+        .left-number {
+          margin-top: 5px;
+        }
+
+        .left-appro {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          margin-top: 3px;
+
+          .appro {
+            margin-left: 9px;
+          }
+        }
+      }
+
+      .price-right {
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        height: 100%;
+
+        .right-number-top,
+        .right-number-bottom {
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-end;
+          width: 100%;
+          height: 32px;
+
+          .right-number-top-price,
+          .right-number-top-number {
+            margin-left: 10px;
+            text-align: right;
+            div {
+              height: 16px;
+              line-height: 16px;
+              text-align: end;
+            }
+          }
+        }
+        .right-number-bottom {
+          margin-top: 9px;
+        }
+      }
+    }
+
+    .k-line-main {
+      height: 50vh;
+      min-height: 350px;
+      width: 100%;
+      padding: 0 15px;
+    }
+
+    .notifi-classifi {
+      display: flex;
+      flex-direction: row;
+      justify-content: flex-start;
+      align-items: flex-end;
+      margin-top: 15px;
+      width: 100%;
+      padding: 0 15px;
+      box-sizing: border-box;
+      height: 24px;
+
+      .sys-notifi {
+        margin-left: 47px;
+      }
+    }
+  }
+</style>
+<template>
+  <div class="market-conditions">
+    <div class="market-price">
+      <div class="price-left">
+        <div class="left-price pf400 fs14 fc333333">实时价格</div>
+        <div
+          class="left-number pf600 fs20 fc1F2937"
+          :class="getPriceColor(marketInfo.change)">
+          {{ formatNumber(marketInfo.price) }}
+        </div>
+        <div class="left-appro pf500 fs14 fcA8A8A8">
+          ≈{{ formatNumber(marketInfo.fiatPrice) }}
+          <span class="appro pf500 fs14" :class="getUpDownClass(marketInfo.change)">
+            {{ marketInfo.change > 0 ? "+" : "" }}{{ marketInfo.change }}%
+          </span>
+        </div>
+      </div>
+
+      <div class="price-right">
+        <div class="right-number-top">
+          <div class="right-number-top-price">
+            <div class="pf400 fs10 fcA8A8A8">24h 最高价</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.high) }}</div>
+          </div>
+          <div class="right-number-top-number">
+            <div class="pf400 fs10 fcA8A8A8">24h 成交量</div>
+            <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.vol) }}</div>
+          </div>
+        </div>
+        <div class="right-number-bottom">
+          <div class="right-number-top-price">
+            <div class="pf400 fs10 fcA8A8A8">24h 最低价</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.low) }}</div>
+          </div>
+          <div class="right-number-top-number">
+            <div class="pf400 fs10 fcA8A8A8">24h 成交额</div>
+            <div class="pf400 fs10 fc2C3131">
+              {{ abbreviateNumber(marketInfo.amount) }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 周期切换 Tab -->
+    <nav class="time-tabs">
+      <div
+        v-for="tab in tabs"
+        :key="tab"
+        class="tab-item"
+        :style="currentTab === tab ? { backgroundColor: '#F6465D', color: '#fff' } : {}"
+        @click="switchPeriod(tab)">
+        {{ tab }}
+      </div>
+      <div class="tab-item icon">更多 <span class="triangle">◢</span></div>
+      <div class="tab-item icon">
+        <img src="@/assets/icon/bitcoin/lishidingdan.svg" alt="" />
+      </div>
+    </nav>
+
     <div class="k-line-main">
       <KlineChart
         ref="klineRef"