Jelajahi Sumber

今日工作:1 K线图核心组件封装 2 WebSocket 实时数据流对接 K线最基础的功能已实现 时间戳判断自动生成下一根 K 线(支持 1m/1h/1d 等多周期)等

Hexinkui 4 minggu lalu
induk
melakukan
fed4d0ee88

+ 1 - 1
.env.production

@@ -1,2 +1,2 @@
 # 这是你上线后的接口地址
-VUE_APP_BASE_API = 'https://api.your-trading-site.com'
+VUE_APP_BASE_API = '/api'

+ 12 - 0
src/api/index.js

@@ -15,4 +15,16 @@ export function GetCoins() {
     url: '/finance/trading_pair/get_display_coin/',
     method: 'get'
   })
+}
+
+//获取K线
+export function GetCandlestickChart(id) {
+  return request({
+    url: `/finance/trading_pair/${id.symbol}/get_kline/`,
+    method: 'get',
+    params: {
+        interval: id.period,
+        limit: 150
+    }
+  })
 }

+ 46 - 165
src/views/bitcoin/lever/TradeSeconds.vue

@@ -23,25 +23,24 @@
       <div class="stat-grid">
         <div>
           <div class="stat-item">
-          <span class="label">24h 最高价</span>
-          <span class="value">78,776.76</span>
-        </div>
-        <div class="stat-item">
-          <span class="label">24h 成交量 (BTC)</span>
-          <span class="value">78,776.76</span>
-        </div>
+            <span class="label">24h 最高价</span>
+            <span class="value">78,776.76</span>
+          </div>
+          <div class="stat-item">
+            <span class="label">24h 成交量 (BTC)</span>
+            <span class="value">78,776.76</span>
+          </div>
         </div>
         <div>
           <div class="stat-item">
-          <span class="label">24h 最低价</span>
-          <span class="value">78,776.76</span>
-        </div>
-        <div class="stat-item">
-          <span class="label">24h 成交额 (BTC)</span>
-          <span class="value">78,776.76</span>
-        </div>
+            <span class="label">24h 最低价</span>
+            <span class="value">78,776.76</span>
+          </div>
+          <div class="stat-item">
+            <span class="label">24h 成交额 (BTC)</span>
+            <span class="value">78,776.76</span>
+          </div>
         </div>
-
       </div>
     </section>
 
@@ -61,32 +60,41 @@
       </div>
     </nav>
 
+    <!-- 这里的 chart-wrapper 负责控制 padding 和高度 -->
     <div class="chart-wrapper">
-      <div id="k-line-chart" class="kline-container"></div>
+      <!-- 引入封装好的组件,只需传数据和高度 -->
+      <KlineChart
+        :data="kLineData"
+        height="100%"
+      />
     </div>
+
     <ChooseThisCycle></ChooseThisCycle>
     <sellOrder></sellOrder>
-<!--    <MarketPriceAndPlan></MarketPriceAndPlan>-->
+    <!-- <MarketPriceAndPlan></MarketPriceAndPlan> -->
   </div>
 </template>
 
 <script setup>
-import { onMounted, onUnmounted, ref , watch} from 'vue'
-import { useRoute } from 'vue-router' // 1. 引入 useRoute
-// ✅ 使用 namespace 引入,防止报错
-import * as klinecharts from 'klinecharts'
-import MarketPriceAndPlan from'./components/MarketPriceAndPlan.vue'
-import ChooseThisCycle from'./components/ChooseThisCycle.vue'
-import sellOrder from '@/views/bitcoin/components/sellOrder.vue';
+import { onMounted, ref } from 'vue'
+import { useRoute } from 'vue-router'
+import MarketPriceAndPlan from './components/MarketPriceAndPlan.vue'
+import ChooseThisCycle from './components/ChooseThisCycle.vue'
+import sellOrder from '@/views/bitcoin/components/sellOrder.vue'
+
+// 1. 引入新封装的组件 (请确保路径正确)
+import KlineChart from '@/views/bitcoin/lever/components/KLineChart.vue'
+
 const route = useRoute()
 const symbolTitle = ref(route.params.id || 'BTCUSDT')
+const showFunctions = ref(false) // 补全原始模板中用到的变量
 
 // --- 状态管理 ---
 const currentTab = ref('24h')
 const tabs = ['1h', '6h', '24h', '1w', '1m']
-let chartInstance = null
+const kLineData = ref([]) // 响应式数据,传递给子组件
 
-// --- 数据生成逻辑 ---
+// --- 数据生成逻辑 (保持不变) ---
 const generateData = (baseVal = 3812.74) => {
   const data = []
   let baseTime = new Date().getTime()
@@ -109,120 +117,15 @@ const generateData = (baseVal = 3812.74) => {
 // --- 切换周期逻辑 ---
 const switchPeriod = (period) => {
   currentTab.value = period
-  if (chartInstance) {
-    // 模拟数据刷新
-    const randomStart = 3800 + Math.random() * 100
-    chartInstance.applyNewData(generateData(randomStart))
-  }
+  // 模拟数据刷新,更新 kLineData,子组件会自动监听并重绘
+  const randomStart = 3800 + Math.random() * 100
+  kLineData.value = generateData(randomStart)
 }
 
-// --- 初始化与配置 ---
+// --- 初始化 ---
 onMounted(() => {
-  const chartDOM = document.getElementById('k-line-chart')
-  if (!chartDOM) return
-
-  // 1. 初始化图表
-  chartInstance = klinecharts.init(chartDOM)
-  if (!chartInstance) return
-
-  // 2. 样式常量
-  const targetBlue = '#4A6EF5' // 截图中的蓝色虚线颜色
-  const gridColor = '#F2F4F6'  // 极淡的网格线
-  const textColor = '#929AA5'  // 灰色文字
-
-  // 3. 核心配置 (setStyleOptions)
-  chartInstance.setStyleOptions({
-    grid: {
-      show: true,
-      horizontal: {
-        show: true,
-        size: 1,
-        color: gridColor,
-        style: 'dash',
-        dashValue: [5, 5]
-      },
-      vertical: { show: false }
-    },
-    candle: {
-      type: 'candle_solid',
-      bar: {
-        upColor: '#2EBD85',
-        downColor: '#F6465D',
-        noChangeColor: '#2EBD85'
-      },
-      // ✅ 重点:蓝色价格指示线配置
-      priceMark: {
-        show: true,
-        high: { show: false },
-        low: { show: false },
-        last: {
-          show: true,
-          // 强制无论涨跌都显示蓝色
-          upColor: targetBlue,
-          downColor: targetBlue,
-          line: { show: true, style: 'dash', dashValue: [4, 3] },
-          text: {
-            show: true,
-            color: '#FFFFFF',
-            size: 11,
-            paddingLeft: 4,
-            paddingRight: 4,
-            borderRadius: 2
-          }
-        }
-      },
-      tooltip: {
-        // 只有按压时才显示十字光标
-        showRule: 'follow_cross',
-        showType: 'rect',
-        dataSource: 'none', // 隐藏浮层数据,保持清爽
-        crosshair: {
-          show: true,
-          horizontal: { line: { style: 'dash', color: textColor } },
-          vertical: { line: { style: 'dash', color: textColor } }
-        }
-      }
-    },
-    // ✅ 重点:技术指标(VOL)颜色
-    technicalIndicator: {
-      bar: {
-        upColor: '#2EBD85',
-        downColor: '#F6465D',
-        noChangeColor: '#2EBD85'
-      }
-    },
-    // ✅ 重点:隐藏坐标轴线,只保留文字
-    xAxis: {
-      axisLine: { show: false },
-      tickLine: { show: false },
-      tickText: { color: textColor, size: 10, paddingTop: 8 }
-    },
-    yAxis: {
-      type: 'normal',
-      position: 'right',
-      inside: true,
-      axisLine: { show: false },
-      tickLine: { show: false },
-      tickText: { color: textColor, size: 10, paddingLeft: 8 }
-    },
-    separator: { size: 0 } // 去掉指标和K线之间的分割线
-  })
-
-  // 4. 创建副图指标 (VOL)
-  chartInstance.createTechnicalIndicator('VOL', false, { id: 'pane_1', heightRatio: 0.2 })
-
-  // 5. 加载数据并设置缩放
-  chartInstance.applyNewData(generateData())
-  if (chartInstance.setDataSpace) {
-  chartInstance.setDataSpace(7)
-}
-  // chartInstance.setBarSpace(7) // 设置蜡烛宽度
-})
-
-onUnmounted(() => {
-  if (chartInstance) {
-    klinecharts.dispose('k-line-chart')
-  }
+  // 页面加载时生成初始数据
+  kLineData.value = generateData()
 })
 </script>
 
@@ -233,25 +136,20 @@ onUnmounted(() => {
   max-width: 375px;
   margin: 0 auto;
   background-color: #fff;
-  /* 使用系统字体,还原原生质感 */
   font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Roboto, Arial, sans-serif;
   color: #333;
-  /* 防止横向滚动条 */
   padding-top: 50px;
   padding-bottom: 60px;
   overflow-x: hidden;
 }
 
-/* 🔥🔥 核心修改:定义一个公共的左右内边距
-   给 header, market-info, time-tabs, chart-wrapper 都加上
-*/
+/* 公共内边距 */
 .header,
 .market-info,
 .time-tabs,
 .chart-wrapper {
   padding-left: 15px;
   padding-right: 15px;
-  /* 关键:确保 padding 不会撑大 width: 100% */
   box-sizing: border-box;
 }
 
@@ -260,7 +158,6 @@ onUnmounted(() => {
   display: flex;
   justify-content: space-between;
   align-items: center;
- /* padding-top: 12px;*/
   padding-bottom: 12px;
 }
 .title {
@@ -269,10 +166,6 @@ onUnmounted(() => {
   display: inline-block;
   margin-left: 10px;
 }
-.icon-menu, .icon-more {
-  font-size: 18px;
-  cursor: pointer;
-}
 
 /* --- 2. 行情信息 --- */
 .market-info {
@@ -286,7 +179,6 @@ onUnmounted(() => {
   font-weight: 600;
   line-height: 1.2;
   color: #111;
-  /* 稍微调整一下字间距,防止数字太紧 */
   letter-spacing: -0.5px;
 }
 .sub-info {
@@ -301,47 +193,41 @@ onUnmounted(() => {
 .sub-info .percent.up {
   color: #2EBD85;
 }
+
 /* 统计网格 */
 .stat-grid {
   display: flex;
   flex-direction: row;
   text-align: right;
   gap: 5px;
-
 }
 .stat-item {
   display: flex;
   flex-direction: column;
-
 }
 .stat-item .label {
   font-size: 11px;
   color: #999;
-  /* 稍微缩小字体 */
   transform: scale(0.9);
   transform-origin: right bottom;
-  white-space: nowrap; /* 防止文字换行 */
+  white-space: nowrap;
 }
 .stat-item .value {
   font-size: 11px;
   color: #333;
-  font-family: "DIN", -apple-system, sans-serif; /* 如果有数字字体更好 */
+  font-family: "DIN", -apple-system, sans-serif;
 }
 
 /* --- 3. Tabs --- */
 .time-tabs {
   display: flex;
   align-items: center;
-  /*gap: 5px;*/
   justify-content: space-between;
-  /*//padding-top: 8px;*/
-  /*padding-bottom: 8px;*/
-  /* 你的截图中似乎没有底边框,这里保持干净 */
 }
 .tab-item {
   font-size: 14px;
   color: #929AA5;
-  padding: 4px 10px; /* 稍微减小内边距,适应 15px 的两侧挤压 */
+  padding: 4px 10px;
   border-radius: 6px;
   cursor: pointer;
   font-weight: 500;
@@ -360,14 +246,9 @@ onUnmounted(() => {
 }
 
 /* --- 4. 图表容器 --- */
+/* 父组件控制高度和边距,子组件填充 100% */
 .chart-wrapper {
   width: 100%;
   height: 275px;
-  /*margin-top: 5px;*/
-  /* 🔥 这里加了 padding 后,K线图就会自动往里缩,左右留出白边 */
-}
-.kline-container {
-  width: 100%;
-  height: 100%;
 }
 </style>

+ 106 - 99
src/views/bitcoin/lever/components/KLineChart.vue

@@ -1,126 +1,133 @@
 <template>
-  <div class="kline-wrapper">
-    <!-- 时间周期 -->
-    <div class="chart-header">
-      <div class="time-intervals">
-        <span class="active">15分</span>
-        <span>1小时</span>
-        <span>4小时</span>
-        <span>日线</span>
-      </div>
-    </div>
-
-    <div ref="chartContainer" class="chart-container"></div>
-
-    <!-- 指标切换 -->
-    <div class="chart-footer">
-      <span class="indicator-btn" @click="setMainIndicator('MA')">MA</span>
-      <span class="indicator-btn" @click="setMainIndicator('BOLL')">BOLL</span>
-      <span class="divider">|</span>
-      <span class="indicator-btn" @click="setSubIndicator('VOL')">VOL</span>
-      <span class="indicator-btn" @click="setSubIndicator('MACD')">MACD</span>
-      <span class="indicator-btn" @click="setSubIndicator('KDJ')">KDJ</span>
-    </div>
+  <div class="kline-wrapper" :style="{ height: height }">
+    <div ref="chartContainer" class="kline-chart"></div>
   </div>
 </template>
 
 <script setup>
-import { onMounted, onUnmounted, ref } from 'vue'
-import { init, dispose } from 'klinecharts'
+import { onMounted, onBeforeUnmount, ref, watch, nextTick, toRaw } from 'vue'
+import * as klinecharts from 'klinecharts'
+
+const props = defineProps({
+  data: { type: Array, default: () => [] },
+  height: { type: String, default: '100%' },
+  precision: { type: Object, default: () => ({ price: 2, volume: 2 }) },
+  colors: {
+    type: Object,
+    default: () => ({
+      up: '#2EBD85',
+      down: '#F6465D',
+      grid: '#F2F4F6',
+      text: '#929AA5',
+      targetLine: '#4A6EF5'
+    })
+  }
+})
 
 const chartContainer = ref(null)
-let chartObj = null
-
-// 生成 mock 数据
-function getMockDataList() {
-  const dataList = []
-  let ts = Date.now()
-  let price = 40000
-
-  for (let i = 0; i < 500; i++) {
-    const timestamp = ts - (500 - i) * 60 * 1000 * 15
-    const random = (Math.random() - 0.5) * 200
-    const open = price
-    const close = price + random
-    const high = Math.max(open, close) + Math.random() * 50
-    const low = Math.min(open, close) - Math.random() * 50
-    const volume = Math.random() * 1000 + 500
-
-    dataList.push({ timestamp, open, high, low, close, volume })
-    price = close
+let chartInstance = null
+
+const initChart = () => {
+  if (!chartContainer.value) return
+  if (chartInstance) klinecharts.dispose(chartContainer.value)
+
+  chartInstance = klinecharts.init(chartContainer.value)
+
+  // 设置精度
+  const { price, volume } = props.precision
+  if (chartInstance.setPriceVolumePrecision) {
+    chartInstance.setPriceVolumePrecision(price, volume)
+  } else if (chartInstance.setPrecision) {
+    chartInstance.setPrecision(price, volume)
   }
 
-  return dataList
+  // 样式配置
+  const { up, down, grid, text, targetLine } = props.colors
+  chartInstance.setStyleOptions({
+    grid: { show: true, horizontal: { show: true, size: 1, color: grid, style: 'dash', dashValue: [5, 5] }, vertical: { show: false } },
+    candle: {
+      type: 'candle_solid',
+      bar: { upColor: up, downColor: down, noChangeColor: up },
+      priceMark: {
+        show: true,
+        last: { show: true, upColor: up, downColor: down, line: { show: true, style: 'dash' }, text: { show: true, color: '#FFF', paddingLeft: 4, paddingRight: 4, borderRadius: 2 } }
+      },
+      tooltip: { showRule: 'follow_cross', showType: 'rect', dataSource: 'none' }
+    },
+    xAxis: { axisLine: { show: false }, tickLine: { show: false }, tickText: { color: text, size: 10, paddingTop: 8 } },
+    yAxis: { inside: true, axisLine: { show: false }, tickLine: { show: false }, tickText: { color: text, size: 10, paddingLeft: 8 } },
+  })
+
+  chartInstance.createTechnicalIndicator('VOL', false, { id: 'pane_1', heightRatio: 0.2 })
+
+  // 初始加载
+  if (props.data && props.data.length > 0) {
+    chartInstance.applyNewData(toRaw(props.data))
+  }
 }
 
-onMounted(() => {
-  if (!chartContainer.value) {
-    console.error('找不到图表容器')
+// --- 🔥 核心修复:智能判断是“更新”还是“重置” ---
+watch(() => props.data, (newData) => {
+  if (!chartInstance) return
+
+  const rawData = toRaw(newData)
+  const currentList = chartInstance.getDataList()
+
+  // 1. 如果新数据为空,清空图表
+  if (rawData.length === 0) {
+    chartInstance.clearData()
     return
   }
 
-  // 初始化 v8 图表
-  chartObj = init(chartContainer.value)
-
-  console.log('chartObj 实例:', chartObj)
+  // 2. 如果当前图表为空,直接加载
+  if (currentList.length === 0) {
+    chartInstance.applyNewData(rawData)
+    return
+  }
 
-  if (!chartObj) {
-    console.error('图表初始化失败')
+  // 3. 🔥 关键判断:
+  // 如果第一根 K 线的时间戳变了,说明切换了周期或币种 -> 全量重置
+  const firstOld = currentList[0]
+  const firstNew = rawData[0]
+  if (firstOld.timestamp !== firstNew.timestamp) {
+    chartInstance.applyNewData(rawData)
     return
   }
 
-  // 设置主图 & 副图指标(v8 用 createIndicator)
-  chartObj.createIndicator('MA', false, { id: 'candle_pane' })
-  chartObj.createIndicator('VOL', false, { id: 'pane_1' })
+  // 4. 如果第一根时间没变,说明是实时跳动或追加 -> 增量更新
+  if (rawData.length > 0) {
+    const lastData = rawData[rawData.length - 1]
+    chartInstance.updateData(lastData)
+  }
+}, { deep: true })
 
-  // v8 的方法是 applyNewData(不是 applyData)
-  const data = getMockDataList()
-  chartObj.applyNewData(data)
-})
+// 监听精度
+watch(() => props.precision, (val) => {
+  if (chartInstance) {
+    if (chartInstance.setPriceVolumePrecision) chartInstance.setPriceVolumePrecision(val.price, val.volume)
+    else if (chartInstance.setPrecision) chartInstance.setPrecision(val.price, val.volume)
+  }
+}, { deep: true })
 
-onUnmounted(() => {
-  if (chartObj) dispose(chartContainer.value)
+onMounted(() => {
+  nextTick(() => initChart())
+  window.addEventListener('resize', handleResize)
 })
 
-// 切换主图指标
-function setMainIndicator(name) {
-  chartObj.createIndicator(name, false, { id: 'candle_pane' })
-}
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  if (chartInstance) {
+    klinecharts.dispose(chartContainer.value)
+    chartInstance = null
+  }
+})
 
-// 切换副图指标
-function setSubIndicator(name) {
-  chartObj.createIndicator(name, false, { id: 'pane_1' })
+const handleResize = () => {
+  if (chartInstance) chartInstance.resize()
 }
 </script>
 
 <style scoped>
-.kline-wrapper {
-  display: flex;
-  flex-direction: column;
-  width: 100%;
-}
-
-.chart-header, .chart-footer {
-  display: flex;
-  padding: 10px 15px;
-  background: #fff;
-}
-
-.time-intervals span, .indicator-btn {
-  font-size: 12px;
-  color: #666;
-  margin-right: 15px;
-  cursor: pointer;
-}
-
-.time-intervals span.active {
-  font-weight: bold;
-  color: #333;
-}
-
-.chart-container {
-  width: 100%;
-  height: 450px;
-  background: #fff;
-}
-</style>
+.kline-wrapper { width: 100%; position: relative; }
+.kline-chart { width: 100%; height: 100%; }
+</style>

+ 2 - 19
src/views/index/components/HotCoin.vue

@@ -38,7 +38,7 @@
     </div>
     <div class="coin-body" >
      <div v-show="item.change_rate" class="body-item" v-for="(item, index) in coinList" :key="index">
-      <div class="item-left" @click="router.push(`/marketDetails`)">
+      <div class="item-left" @click="router.push({ path: '/marketDetails', query: { id: item.id,type: item.symbol.toLowerCase()} })">
         <div class="coin-img" >
           <img :src="item.logo" alt="" />
         </div>
@@ -54,23 +54,6 @@
       </div>
       <div class="item-right pf500 fs12 fcFFFFFF" :class="getChangeColor(item.change_rate)">{{ formatChange(item.change_rate) }}</div>
     </div>
-<!--      <div class="body-item" v-for="(item, index) in 5" :key="index">-->
-<!--        <div class="item-left">-->
-<!--          <div class="coin-img">-->
-<!--            <img src="../../../assets/img/index/Frame 7.svg" alt="" />-->
-<!--          </div>-->
-<!--          <div class="coin-name">-->
-<!--            <div class="upper-name pf500 fs14 fc2C3131">Ethereum</div>-->
-<!--            <div class="letter-name pf400 fs10 fcA9A9A9">ETH</div>-->
-<!--          </div>-->
-<!--          <div class="coin-echars"></div>-->
-<!--          <div class="coin-price">-->
-<!--            <div class="upper-price pf500 fs14 fc2C3131">48.503.12</div>-->
-<!--            <div class="letter-price pf400 fs10 fcA9A9A9">¥ 4250.00</div>-->
-<!--          </div>-->
-<!--        </div>-->
-<!--        <div class="item-right pf500 fs12 fcFFFFFF">+2.18%</div>-->
-<!--      </div>-->
     </div>
   </div>
 </template>
@@ -132,7 +115,7 @@ const initWebSocket = () => {
   // const host = 'http://localhost:8080'
   // 等调试通了,以后上线前可以改回这样:
   const host = process.env.NODE_ENV === 'production'
-    ? '63.141.230.43:57676'
+    ? 'backend.66linknow.com'
     : 'localhost:8080' // 开发环境走代理
     const wsUrl = `ws://${host}/ws/kline/${query}`
 

+ 1 - 0
src/views/market/details/EntrustingOrder.vue

@@ -29,6 +29,7 @@
 <style lang="less" scoped>
   .entrusting-order {
     width: 100%;
+    padding: 0 15px;
 
     .order-header {
       display: flex;

+ 459 - 145
src/views/market/details/MarketConditions.vue

@@ -4,209 +4,523 @@
       <div class="price-left">
         <div class="left-price pf400 fs14 fc333333">
           实时价格
-          <img src="../../../assets/icon/market/bottom-arrow.svg" alt="" />
         </div>
-        <div class="left-number pf600 fs20 fc1F2937">1,125,158.00</div>
+        <div class="left-number pf600 fs20 fc1F2937" :class="getPriceColor(marketInfo.change)">
+          {{ formatNumber(marketInfo.price) }}
+        </div>
         <div class="left-appro pf500 fs14 fcA8A8A8">
-          ≈35,458.00
-          <div class="appro pf500 fs14 fc45B26B">+1.42%</div>
+          ≈{{ 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">78,776.76</div>
+            <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.high) }}</div>
           </div>
           <div class="right-number-top-number">
-            <div class="pf400 fs10 fcA8A8A8">24h 成交量 (BTC)</div>
-            <div class="pf400 fs10 fc2C3131">78,776.76</div>
+            <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">78,776.76</div>
+            <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 成交量 (BTC)</div>
-            <div class="pf400 fs10 fc2C3131">78,776.76</div>
+            <div class="pf400 fs10 fcA8A8A8">24h 成交</div>
+            <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.amount) }}</div>
           </div>
         </div>
       </div>
     </div>
-    <div class="k-line-main"></div>
-    <div class="notifi-classifi">
-      <div class="pf600 fs14 fc121212" @click="messageChange('entrustingOrder')">
-        委托挂单
-      </div>
+
+    <!-- 周期切换 Tab -->
+    <nav class="time-tabs">
       <div
-        class="sys-notifi pf600 fs14 fcA8A8A8"
-        @click="messageChange('latestTransactions')">
-        最新成交
+        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>
-    <component :is="currentComponent" />
+
+    <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" />
   </div>
 </template>
+
 <script setup>
-  import EntrustingOrder from "./EntrustingOrder.vue";
-  import LatestTransactions from "./LatestTransactions.vue";
-  import { ref, computed } from "vue";
-
-  const current = ref("entrustingOrder");
-  const componentsMap = {
-    entrustingOrder: EntrustingOrder,
-    latestTransactions: LatestTransactions,
-  };
-  const currentComponent = computed(() => componentsMap[current.value]);
+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.00, high: '0.00', low: '0.00', vol: '0', amount: '0'
+});
+
+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 messageChange = (key) => {
-    current.value = key;
+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;
+
+    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);
+        }
+      }
+    }
+  } 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>
-  .market-conditions {
+.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;
+}
+
+.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;
+  width: 100%;
+
+  .market-price {
     display: flex;
-    flex-direction: column;
-    justify-content: flex-start;
-    align-items: center;
-    width: 349px;
+    flex-direction: row;
+    justify-content: space-between;
+    margin-top: 8px;
+    width: 100%;
+    height: 73px;
+    padding: 0 15px;
+    box-sizing: border-box;
 
-    .market-price {
+    .price-left {
       display: flex;
-      flex-direction: row;
-      justify-content: space-between;
-      margin-top: 8px;
-      width: 100%;
-      height: 73px;
+      flex-direction: column;
+      justify-content: flex-start;
+      width: 144px;
+      height: 69px;
 
-      .price-left {
+      .left-price {
         display: flex;
-        flex-direction: column;
+        flex-direction: row;
         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;
-          }
-        }
+        align-items: center;
+        height: 18px;
 
-        .left-number {
-          margin-top: 5px;
+        img {
+          margin-left: 5px;
+          width: 8px;
+          height: 4px;
         }
+      }
 
-        .left-appro {
-          display: flex;
-          flex-direction: row;
-          justify-content: flex-start;
-          align-items: center;
-          margin-top: 3px;
-
-          .appro {
-            margin-left: 9px;
-          }
-        }
+      .left-number {
+        margin-top: 5px;
       }
 
-      .price-right {
+      .left-appro {
         display: flex;
-        flex-direction: column;
+        flex-direction: row;
         justify-content: flex-start;
-        height: 100%;
-
-        .right-number-top {
-          display: flex;
-          flex-direction: row;
-          justify-content: flex-start;
-          width: 189px;
-          height: 32px;
-
-          .right-number-top-price {
-            width: 93px;
-            height: 32px;
-
-            div {
-              height: 16px;
-              line-height: 16px;
-              text-align: end;
-            }
-          }
+        align-items: center;
+        margin-top: 3px;
 
-          .right-number-top-number {
-            width: 93px;
-            height: 32px;
-
-            div {
-              height: 16px;
-              line-height: 16px;
-              text-align: end;
-            }
-          }
+        .appro {
+          margin-left: 9px;
         }
+      }
+    }
 
-        .right-number-bottom {
-          display: flex;
-          flex-direction: row;
-          justify-content: flex-start;
-          margin-top: 9px;
-          width: 189px;
-          height: 32px;
-
-          .right-number-top-price {
-            width: 93px;
-            height: 32px;
-
-            div {
-              height: 16px;
-              line-height: 16px;
-              text-align: end;
-            }
-          }
+    .price-right {
+      display: flex;
+      flex-direction: column;
+      justify-content: flex-start;
+      height: 100%;
 
-          .right-number-top-number {
-            width: 93px;
-            height: 32px;
+      .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 {
-      margin-top: 15px;
-      width: 100%;
-      height: 283px;
-      background: pink;
-    }
+  .k-line-main {
+    height: 50vh;
+    min-height: 350px;
+    width: 100%;
+  }
 
-    .notifi-classifi {
-      display: flex;
-      flex-direction: row;
-      justify-content: flex-start;
-      align-items: flex-end;
-      margin-top: 15px;
-      width: 349px;
-      height: 24px;
+  .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;
-      }
+    .sys-notifi {
+      margin-left: 47px;
     }
   }
-</style>
+}
+</style>

+ 2 - 2
vue.config.js

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