Ver Fonte

Merge branch 'web3_transection'

Hexinkui há 3 semanas atrás
pai
commit
5751e7b4c0

+ 36 - 8
src/views/bitcoin/lever/components/KLineChart.vue

@@ -5,7 +5,7 @@
 </template>
 
 <script setup>
-import { onMounted, onBeforeUnmount, ref, watch, nextTick, toRaw } from 'vue'
+import { onMounted, onBeforeUnmount, ref, watch, nextTick, toRaw, defineExpose } from 'vue'
 import * as klinecharts from 'klinecharts'
 
 const props = defineProps({
@@ -58,6 +58,8 @@ const initChart = () => {
     yAxis: { inside: true, axisLine: { show: false }, tickLine: { show: false }, tickText: { color: text, size: 10, paddingLeft: 8 } },
   })
 
+  // 默认加载 MA 和 VOL
+  chartInstance.createTechnicalIndicator('MA', false, { id: 'candle_pane' })
   chartInstance.createTechnicalIndicator('VOL', false, { id: 'pane_1', heightRatio: 0.2 })
 
   // 初始加载
@@ -66,27 +68,55 @@ const initChart = () => {
   }
 }
 
-// --- 🔥 核心修复:智能判断是“更新”还是“重置” ---
+// --- 🎯 新增:暴露给父组件的方法 ---
+
+// 1. 设置主图指标
+const setMainIndicator = (name) => {
+  if (!chartInstance) return
+  // 移除常见主图指标
+  chartInstance.removeTechnicalIndicator('candle_pane', 'MA')
+  chartInstance.removeTechnicalIndicator('candle_pane', 'BOLL')
+
+  if (name && name !== 'Hide') {
+    chartInstance.createTechnicalIndicator(name, false, { id: 'candle_pane' })
+  }
+}
+
+// 2. 设置副图指标
+const setSubIndicator = (name) => {
+  if (!chartInstance) return
+  // 移除常见副图指标
+  const subs = ['VOL', 'MACD', 'KDJ', 'RSI', 'WR']
+  subs.forEach(s => chartInstance.removeTechnicalIndicator('pane_1', s))
+
+  if (name && name !== 'Hide') {
+    chartInstance.createTechnicalIndicator(name, false, { id: 'pane_1', heightRatio: 0.2 })
+  }
+}
+
+defineExpose({
+  setMainIndicator,
+  setSubIndicator
+})
+
+// --- 原有逻辑保持不变 ---
+
 watch(() => props.data, (newData) => {
   if (!chartInstance) return
 
   const rawData = toRaw(newData)
   const currentList = chartInstance.getDataList()
 
-  // 1. 如果新数据为空,清空图表
   if (rawData.length === 0) {
     chartInstance.clearData()
     return
   }
 
-  // 2. 如果当前图表为空,直接加载
   if (currentList.length === 0) {
     chartInstance.applyNewData(rawData)
     return
   }
 
-  // 3. 🔥 关键判断:
-  // 如果第一根 K 线的时间戳变了,说明切换了周期或币种 -> 全量重置
   const firstOld = currentList[0]
   const firstNew = rawData[0]
   if (firstOld.timestamp !== firstNew.timestamp) {
@@ -94,14 +124,12 @@ watch(() => props.data, (newData) => {
     return
   }
 
-  // 4. 如果第一根时间没变,说明是实时跳动或追加 -> 增量更新
   if (rawData.length > 0) {
     const lastData = rawData[rawData.length - 1]
     chartInstance.updateData(lastData)
   }
 }, { deep: true })
 
-// 监听精度
 watch(() => props.precision, (val) => {
   if (chartInstance) {
     if (chartInstance.setPriceVolumePrecision) chartInstance.setPriceVolumePrecision(val.price, val.volume)

+ 320 - 521
src/views/market/details/MarketConditions.vue

@@ -1,11 +1,10 @@
 <template>
-  <div class="market-conditions">
+  <div class="market-conditions" @click="closePopups">
+    <!-- 1. 头部行情 -->
     <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)">
+        <div class="left-number pf600 fs20 fc1F2937" :class="getPriceColor(marketInfo.change)">
           {{ formatNumber(marketInfo.price) }}
         </div>
         <div class="left-appro pf500 fs14 fcA8A8A8">
@@ -15,7 +14,6 @@
           </span>
         </div>
       </div>
-
       <div class="price-right">
         <div class="right-number-top">
           <div class="right-number-top-price">
@@ -34,30 +32,72 @@
           </div>
           <div class="right-number-top-number">
             <div class="pf400 fs10 fcA8A8A8">24h 成交额</div>
-            <div class="pf400 fs10 fc2C3131">
-              {{ abbreviateNumber(marketInfo.amount) }}
-            </div>
+            <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.amount) }}</div>
           </div>
         </div>
       </div>
     </div>
 
-    <!-- 周期切换 Tab -->
+    <!-- 2. 周期切换 Tab -->
     <nav class="time-tabs">
+      <!-- 常用周期 -->
       <div
-        v-for="tab in tabs"
+        v-for="tab in visibleTabs"
         :key="tab"
         class="tab-item"
         :style="currentTab === tab ? { backgroundColor: '#F6465D', color: '#fff' } : {}"
-        @click="switchPeriod(tab)">
-        {{ tab }}
+        @click.stop="switchPeriod(tab)">
+        {{ getTabLabel(tab) }}
       </div>
-      <div class="tab-item icon">更多 <span class="triangle">◢</span></div>
-      <div class="tab-item icon">
+
+      <!-- 更多按钮 (下拉菜单) -->
+      <div class="tab-item icon-btn" @click.stop="toggleMore">
+        <span :class="{ 'active-text': isMoreActive }">更多</span>
+        <span class="triangle">◢</span>
+
+        <!-- 下拉菜单 -->
+        <div class="dropdown-menu" v-show="showMoreMenu">
+          <div
+            v-for="mt in moreTabs"
+            :key="mt"
+            class="drop-item"
+            :class="{ active: currentTab === mt }"
+            @click.stop="switchPeriod(mt)">
+            {{ getTabLabel(mt) }}
+          </div>
+        </div>
+      </div>
+
+      <!-- 指标按钮 -->
+      <div class="tab-item icon-btn" @click.stop="toggleIndicators">
         <img src="@/assets/icon/bitcoin/lishidingdan.svg" alt="" />
       </div>
     </nav>
 
+    <!-- 3. 指标设置面板 (点击指标图标弹出) -->
+    <div class="indicator-panel" v-show="showIndicatorMenu" @click.stop>
+      <div class="panel-section">
+        <div class="section-title">主图</div>
+        <div class="btn-group">
+          <div class="idx-btn" :class="{ active: mainIdx === 'MA' }" @click="changeMain('MA')">MA</div>
+          <div class="idx-btn" :class="{ active: mainIdx === 'BOLL' }" @click="changeMain('BOLL')">BOLL</div>
+          <div class="idx-btn" :class="{ active: mainIdx === 'Hide' }" @click="changeMain('Hide')">隐藏</div>
+        </div>
+      </div>
+      <div class="panel-section">
+        <div class="section-title">副图</div>
+        <div class="btn-group">
+          <div class="idx-btn" :class="{ active: subIdx === 'VOL' }" @click="changeSub('VOL')">VOL</div>
+          <div class="idx-btn" :class="{ active: subIdx === 'MACD' }" @click="changeSub('MACD')">MACD</div>
+          <div class="idx-btn" :class="{ active: subIdx === 'KDJ' }" @click="changeSub('KDJ')">KDJ</div>
+          <div class="idx-btn" :class="{ active: subIdx === 'RSI' }" @click="changeSub('RSI')">RSI</div>
+          <div class="idx-btn" :class="{ active: subIdx === 'WR' }" @click="changeSub('WR')">WR</div>
+          <div class="idx-btn" :class="{ active: subIdx === 'Hide' }" @click="changeSub('Hide')">隐藏</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 4. K线图组件 -->
     <div class="k-line-main">
       <KlineChart
         ref="klineRef"
@@ -66,529 +106,288 @@
         :precision="{ price: getPricePrecision(marketInfo.price), volume: 2 }" />
     </div>
 
+    <!-- 5. 底部挂单/成交 -->
     <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 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" />
+    <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);
+import { ref, computed, onMounted, onBeforeUnmount, watch } 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 visibleTabs = ["15m", "1h", "4h", "1d"];
+// 下拉菜单里的周期
+const moreTabs = ["1m", "5m", "30m", "1w", "1M"];
+const isMoreActive = computed(() => moreTabs.includes(currentTab.value));
+
+const getTabLabel = (t) => {
+  const map = { '1m':'1分', '5m':'5分', '15m':'15分', '30m':'30分', '1h':'1小时', '4h':'4小时', '1d':'日线', '1w':'周线', '1M':'月线' };
+  return map[t] || t;
+};
+
+// --- UI 状态 ---
+const showMoreMenu = ref(false);
+const showIndicatorMenu = ref(false);
+const mainIdx = ref('MA');
+const subIdx = ref('VOL');
+const klineRef = ref(null); // 引用子组件
+
+// --- 交互方法 ---
+const toggleMore = () => {
+  showIndicatorMenu.value = false;
+  showMoreMenu.value = !showMoreMenu.value;
+};
+
+const toggleIndicators = () => {
+  showMoreMenu.value = false;
+  showIndicatorMenu.value = !showIndicatorMenu.value;
+};
+
+const closePopups = () => {
+  showMoreMenu.value = false;
+  showIndicatorMenu.value = false;
+};
+
+const switchPeriod = (period) => {
+  if (currentTab.value === period) return;
+  currentTab.value = period;
+  showMoreMenu.value = false;
+  kLineData.value = [];
+  getKlineData();
+};
+
+const changeMain = (name) => {
+  mainIdx.value = name;
+  if (klineRef.value) klineRef.value.setMainIndicator(name);
+};
+
+const changeSub = (name) => {
+  subIdx.value = name;
+  if (klineRef.value) klineRef.value.setSubIndicator(name);
+};
+
+// --- 下面是 WebSocket 和数据逻辑 (保持你原有的逻辑) ---
+const kLineData = ref([]);
+const socket = ref(null);
+const WS_BASE_URL = "ws://backend.66linknow.com/ws/kline/";
+const HEARTBEAT_INTERVAL = 15000;
+const RECONNECT_DELAY = 3000;
+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]);
+
+const getKlineData = async () => {
+  if (typeof GetCandlestickChart !== "function") return;
+  try {
+    const res = await GetCandlestickChart({ symbol: symbolId.value, period: currentTab.value });
+    let rawList = Array.isArray(res) ? res : (res && res.data ? 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;
+
+      if (marketInfo.value.price === "0.00") {
+        const lastBar = formattedData[formattedData.length - 1];
+        let totalVol = 0;
+        formattedData.forEach(i => totalVol += i.volume);
+        marketInfo.value = {
+          price: lastBar.close, fiatPrice: lastBar.close, change: 0,
+          high: lastBar.high, low: lastBar.low, vol: totalVol, amount: totalVol * lastBar.close
+        };
       }
-    } 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) {
+  } catch (error) { console.error("API Error", error); }
+};
+
+const connectWebSocket = () => {
+  closeWebSocket();
+  const symbolStr = String(route.query.type || "btcusdt").toLowerCase();
+  const url = `${WS_BASE_URL}?symbol=${symbolStr}`;
+  try {
+    socket.value = new WebSocket(url);
+    socket.value.onopen = () => { startHeartbeat(); };
+    socket.value.onmessage = (event) => { handleSocketMessage(event.data); };
+    socket.value.onclose = () => {
+      stopHeartbeat();
       if (!isUnmounted) {
+        clearTimeout(reconnectTimer);
         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");
+  } 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(() => { socket.value?.readyState === 1 && socket.value.send("ping"); }, HEARTBEAT_INTERVAL); };
+const stopHeartbeat = () => { clearInterval(heartbeatTimer); heartbeatTimer = null; };
+
+const handleSocketMessage = (msgStr) => {
+  try {
+    if (msgStr === "pong") return;
+    const rawData = JSON.parse(msgStr);
+    const msg = rawData.data || rawData;
+
+    if (msg.e === "kline") {
+      const k = msg.k;
+      if (k.i !== currentTab.value) return;
+      const newBar = { timestamp: Number(k.t), open: parseFloat(k.o), high: parseFloat(k.h), low: parseFloat(k.l), close: parseFloat(k.c), volume: parseFloat(k.v) };
+      updateKlineData(newBar);
+      marketInfo.value.price = newBar.close;
+      marketInfo.value.fiatPrice = newBar.close;
+    } else if (msg.e === "24hrTicker") {
+      marketInfo.value.change = parseFloat(msg.P).toFixed(2);
+      marketInfo.value.high = msg.h; marketInfo.value.low = msg.l;
+      marketInfo.value.vol = msg.v; marketInfo.value.amount = msg.q;
+    } else if (rawData.stream?.includes("@depth20")) orderPlacement.value = rawData.data;
+    else if (rawData.stream?.includes("@aggTrade")) latestTransactionData.value = rawData.data;
+  } catch (e) {}
+};
+
+const updateKlineData = (newBar) => {
+  if (!kLineData.value?.length) return;
+  const lastIndex = kLineData.value.length - 1;
+  const lastBar = kLineData.value[lastIndex];
+  if (newBar.timestamp === lastBar.timestamp) kLineData.value.splice(lastIndex, 1, newBar);
+  else if (newBar.timestamp > lastBar.timestamp) kLineData.value.push(newBar);
+};
+
+const handleVisibilityChange = () => { if (document.visibilityState === "visible" && socket.value?.readyState !== 1) connectWebSocket(); };
+
+onMounted(() => { isUnmounted = false; getKlineData(); connectWebSocket(); document.addEventListener("visibilitychange", handleVisibilityChange); });
+onBeforeUnmount(() => { isUnmounted = true; closeWebSocket(); document.removeEventListener("visibilitychange", handleVisibilityChange); });
+watch(symbolId, () => { kLineData.value = []; getKlineData(); connectWebSocket(); });
+const messageChange = (key) => current.value = key;
+const formatNumber = (num) => num ? Number(num).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 }) : "0.00";
+const abbreviateNumber = (v) => { if (!v) return "0.00"; let n = parseFloat(v); if (n>=1e9) return (n/1e9).toFixed(2)+"B"; if (n>=1e6) return (n/1e6).toFixed(2)+"M"; if (n>=1e3) return (n/1e3).toFixed(2)+"K"; return n.toFixed(2); };
+const getPricePrecision = (p) => (p < 1 ? 6 : p < 10 ? 4 : 2);
+const getPriceColor = (c) => (c >= 0 ? "fc45B26B" : "fcF6465D");
+const getUpDownClass = (c) => (c >= 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;
+.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: 10px 15px 0 15px;
+  position: relative;
+  z-index: 20;
+}
+
+.tab-item {
+  font-size: 14px;
+  color: #929aa5;
+  padding: 4px 8px;
+  border-radius: 4px;
+  cursor: pointer;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  position: relative;
+}
+.tab-item.icon-btn { padding: 4px 4px; }
+.active-text { color: #1F2937; font-weight: 600; }
+.triangle { font-size: 8px; margin-left: 2px; transform: scale(0.9); }
+.tab-item img { display: block; height: 16px; width: auto; }
+
+/* 更多周期下拉菜单 */
+.dropdown-menu {
+  position: absolute;
+  top: 30px;
+  left: -20px;
+  width: 80px;
+  background: #fff;
+  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+  border-radius: 6px;
+  padding: 5px 0;
+  z-index: 100;
+  display: flex;
+  flex-direction: column;
+}
+.drop-item {
+  padding: 8px 15px;
+  font-size: 13px;
+  color: #666;
+  text-align: center;
+}
+.drop-item.active { color: #F6465D; background: #fff5f5; }
+
+/* 指标面板 */
+.indicator-panel {
+  position: absolute;
+  top: 155px; /* 调整此值以对齐 K 线图顶部 */
+  left: 15px;
+  right: 15px;
+  background: #fff;
+  box-shadow: 0 4px 15px rgba(0,0,0,0.1);
+  border-radius: 8px;
+  padding: 15px;
+  z-index: 99;
+}
+.panel-section { margin-bottom: 15px; }
+.section-title { font-size: 12px; color: #999; margin-bottom: 8px; }
+.btn-group { display: flex; flex-wrap: wrap; gap: 10px; }
+.idx-btn {
+  padding: 4px 12px; border: 1px solid #eee; border-radius: 14px; font-size: 12px; color: #666; cursor: pointer;
+}
+.idx-btn.active { border-color: #F6465D; color: #F6465D; background-color: #fff5f5; }
+
+/* 容器布局 */
+.market-conditions {
+  display: flex; flex-direction: column; align-items: center; margin-bottom: 50px; width: 100%; position: relative;
+}
+.market-price {
+  display: flex; 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; width: 144px; height: 69px;
+    .left-price { display: flex; align-items: center; height: 18px; }
+    .left-number { margin-top: 5px; }
+    .left-appro { display: flex; align-items: center; margin-top: 3px; .appro { margin-left: 9px; } }
   }
-
-  .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;
-      }
+  .price-right {
+    display: flex; flex-direction: column; height: 100%;
+    .right-number-top, .right-number-bottom {
+      display: flex; 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; }
   }
-</style>
+}
+.k-line-main { height: 50vh; min-height: 350px; width: 100%; padding: 0 15px; margin-top: 10px; }
+.notifi-classifi {
+  display: flex; align-items: flex-end; margin-top: 15px; width: 100%; padding: 0 15px; box-sizing: border-box; height: 24px; border-bottom: 1px solid #f5f5f5; padding-bottom: 5px;
+  .sys-notifi { margin-left: 47px; }
+}
+</style>

+ 2 - 3
vue.config.js

@@ -12,14 +12,13 @@ module.exports = defineConfig({
         // 如果后端在本地,可能是 http://localhost:8000
         // 如果是线上测试服,可能是 http://47.100.xx.xx
           //'http://63.141.230.43:57676',
-        target: 'http://backend.66linknow.com',
-
+        target: 'http://backend.66linknow.com', // ✅ 必须加上协议
         changeOrigin: true, // 允许跨域
 
       },
       // 2.【新增】WebSocket 代理配置
       '/ws/kline': {
-        target: 'http://backend.66linknow.com', // 后端 IP
+        target: 'ws://backend.66linknow.com', // 后端 IP
         changeOrigin: true,
         ws: true // ⚠️ 开启 WebSocket 支持
         // 这里是否需要 pathRewrite 取决于后端路径有没有 /ws