| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- <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" />
- </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.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 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>
- .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: 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%;
- }
- .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>
|