MarketConditions.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. <template>
  2. <div class="market-conditions">
  3. <div class="market-price">
  4. <div class="price-left">
  5. <div class="left-price pf400 fs14 fc333333">
  6. 实时价格
  7. </div>
  8. <div class="left-number pf600 fs20 fc1F2937" :class="getPriceColor(marketInfo.change)">
  9. {{ formatNumber(marketInfo.price) }}
  10. </div>
  11. <div class="left-appro pf500 fs14 fcA8A8A8">
  12. ≈{{ formatNumber(marketInfo.fiatPrice) }}
  13. <span class="appro pf500 fs14" :class="getUpDownClass(marketInfo.change)">
  14. {{ marketInfo.change > 0 ? '+' : '' }}{{ marketInfo.change }}%
  15. </span>
  16. </div>
  17. </div>
  18. <div class="price-right">
  19. <div class="right-number-top">
  20. <div class="right-number-top-price">
  21. <div class="pf400 fs10 fcA8A8A8">24h 最高价</div>
  22. <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.high) }}</div>
  23. </div>
  24. <div class="right-number-top-number">
  25. <div class="pf400 fs10 fcA8A8A8">24h 成交量</div>
  26. <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.vol) }}</div>
  27. </div>
  28. </div>
  29. <div class="right-number-bottom">
  30. <div class="right-number-top-price">
  31. <div class="pf400 fs10 fcA8A8A8">24h 最低价</div>
  32. <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.low) }}</div>
  33. </div>
  34. <div class="right-number-top-number">
  35. <div class="pf400 fs10 fcA8A8A8">24h 成交额</div>
  36. <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.amount) }}</div>
  37. </div>
  38. </div>
  39. </div>
  40. </div>
  41. <!-- 周期切换 Tab -->
  42. <nav class="time-tabs">
  43. <div
  44. v-for="tab in tabs"
  45. :key="tab"
  46. class="tab-item"
  47. :style="currentTab === tab ? { backgroundColor: '#F6465D', color: '#fff' } : {}"
  48. @click="switchPeriod(tab)"
  49. >
  50. {{ tab }}
  51. </div>
  52. <div class="tab-item icon">更多 <span class="triangle">◢</span></div>
  53. <div class="tab-item icon">
  54. <img src="@/assets/icon/bitcoin/lishidingdan.svg" alt="">
  55. </div>
  56. </nav>
  57. <div class="k-line-main">
  58. <KlineChart
  59. ref="klineRef"
  60. :data="kLineData"
  61. height="100%"
  62. :precision="{ price: getPricePrecision(marketInfo.price), volume: 2 }"
  63. />
  64. </div>
  65. <div class="notifi-classifi">
  66. <div class="pf600 fs14" :class="current === 'entrustingOrder' ? 'fc121212' : 'fcA8A8A8'" @click="messageChange('entrustingOrder')">委托挂单</div>
  67. <div class="sys-notifi pf600 fs14" :class="current === 'latestTransactions' ? 'fc121212' : 'fcA8A8A8'" @click="messageChange('latestTransactions')">最新成交</div>
  68. </div>
  69. <component :is="currentComponent" :symbol-id="symbolId" />
  70. </div>
  71. </template>
  72. <script setup>
  73. import { ref, computed, onMounted, onUnmounted, watch, onBeforeUnmount } from "vue";
  74. import { useRoute } from "vue-router";
  75. import { GetCandlestickChart } from "@/api/index.js";
  76. import EntrustingOrder from "./EntrustingOrder.vue";
  77. import LatestTransactions from "./LatestTransactions.vue";
  78. import KlineChart from "@/views/bitcoin/lever/components/KLineChart.vue";
  79. const route = useRoute();
  80. const symbolId = computed(() => route.query.id || '6');
  81. const currentTab = ref('1d');
  82. const tabs = ['1h', '6h', '1d', '1w', '1m'];
  83. const kLineData = ref([]);
  84. const socket = ref(null);
  85. const WS_BASE_URL = 'ws://backend.66linknow.com/ws/kline/';
  86. // --- 生产级配置 ---
  87. const HEARTBEAT_INTERVAL = 15000; // 心跳间隔 15s
  88. const RECONNECT_DELAY = 3000; // 重连延迟 3s
  89. let heartbeatTimer = null;
  90. let reconnectTimer = null;
  91. let isUnmounted = false;
  92. const marketInfo = ref({
  93. price: '0.00', fiatPrice: '0.00', change: 0.00, high: '0.00', low: '0.00', vol: '0', amount: '0'
  94. });
  95. const current = ref("entrustingOrder");
  96. const componentsMap = { entrustingOrder: EntrustingOrder, latestTransactions: LatestTransactions };
  97. const currentComponent = computed(() => componentsMap[current.value]);
  98. // --- 1. 切换周期 ---
  99. const switchPeriod = (period) => {
  100. if (currentTab.value === period) return;
  101. currentTab.value = period;
  102. // 清空数据,触发子组件重置,并重新请求
  103. kLineData.value = [];
  104. getKlineData();
  105. };
  106. // --- 2. HTTP 获取历史数据 ---
  107. const getKlineData = async () => {
  108. if (typeof GetCandlestickChart !== 'function') return;
  109. try {
  110. const res = await GetCandlestickChart({
  111. symbol: symbolId.value,
  112. period: currentTab.value
  113. });
  114. let rawList = [];
  115. if (Array.isArray(res)) rawList = res;
  116. else if (res && Array.isArray(res.data)) rawList = res.data;
  117. if (rawList.length > 0) {
  118. const formattedData = rawList.map(item => ({
  119. timestamp: Number(item[0]),
  120. open: parseFloat(item[1]),
  121. high: parseFloat(item[2]),
  122. low: parseFloat(item[3]),
  123. close: parseFloat(item[4]),
  124. volume: parseFloat(item[5])
  125. }));
  126. formattedData.sort((a, b) => a.timestamp - b.timestamp);
  127. kLineData.value = formattedData;
  128. // 同步合并:如果 WS 已经有最新价,立即修正历史数据最后一根,防止回跳
  129. if (marketInfo.value.price !== '0.00') {
  130. const lastBar = formattedData[formattedData.length - 1];
  131. const realTimePrice = parseFloat(marketInfo.value.price);
  132. lastBar.close = realTimePrice;
  133. // 修正高低
  134. if (realTimePrice > lastBar.high) lastBar.high = realTimePrice;
  135. if (realTimePrice < lastBar.low) lastBar.low = realTimePrice;
  136. }
  137. updateMarketInfoFromKline(formattedData);
  138. }
  139. } catch (error) { console.error("API Error", error); }
  140. };
  141. const updateMarketInfoFromKline = (data) => {
  142. if (!data.length) return;
  143. const lastBar = data[data.length - 1];
  144. // 仅当没数据或 WS 未连接时使用历史数据兜底
  145. if (marketInfo.value.price === '0.00' || !socket.value || socket.value.readyState !== 1) {
  146. const firstBar = data[0];
  147. let maxHigh = -Infinity, minLow = Infinity, totalVol = 0;
  148. data.forEach(item => {
  149. if (item.high > maxHigh) maxHigh = item.high;
  150. if (item.low < minLow) minLow = item.low;
  151. totalVol += item.volume;
  152. });
  153. const changeRate = firstBar.open ? ((lastBar.close - firstBar.open) / firstBar.open) * 100 : 0;
  154. marketInfo.value = {
  155. price: lastBar.close,
  156. change: changeRate.toFixed(2),
  157. high: maxHigh, low: minLow, vol: totalVol, amount: totalVol * lastBar.close,
  158. fiatPrice: lastBar.close
  159. };
  160. }
  161. };
  162. // --- 3. WS 连接 (含心跳设计) ---
  163. const connectWebSocket = () => {
  164. // 清理旧资源
  165. closeWebSocket();
  166. const symbolStr = String(route.query.type || 'btcusdt').toLowerCase();
  167. const url = `${WS_BASE_URL}?symbol=${symbolStr}`;
  168. console.log('WS 连接:', url);
  169. try {
  170. socket.value = new WebSocket(url);
  171. socket.value.onopen = () => {
  172. console.log('✅ WS Connected');
  173. startHeartbeat(); // 启动心跳
  174. };
  175. socket.value.onmessage = (event) => {
  176. handleSocketMessage(event.data);
  177. };
  178. socket.value.onclose = () => {
  179. stopHeartbeat(); // 停止心跳
  180. if (!isUnmounted) {
  181. console.log('⚠️ WS Closed, reconnecting...');
  182. clearTimeout(reconnectTimer);
  183. reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
  184. }
  185. };
  186. socket.value.onerror = (err) => {
  187. // onerror 通常会触发 onclose,由 onclose 处理重连
  188. console.error('❌ WS Error', err);
  189. };
  190. } catch (e) {
  191. if (!isUnmounted) {
  192. reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
  193. }
  194. }
  195. };
  196. const closeWebSocket = () => {
  197. if (socket.value) {
  198. socket.value.close();
  199. socket.value = null;
  200. }
  201. stopHeartbeat();
  202. clearTimeout(reconnectTimer);
  203. };
  204. // --- 心跳逻辑 ---
  205. const startHeartbeat = () => {
  206. stopHeartbeat();
  207. heartbeatTimer = setInterval(() => {
  208. if (socket.value && socket.value.readyState === WebSocket.OPEN) {
  209. // 发送 ping,具体格式看后端要求,一般是字符串 'ping' 或 JSON
  210. socket.value.send("ping");
  211. }
  212. }, HEARTBEAT_INTERVAL);
  213. };
  214. const stopHeartbeat = () => {
  215. if (heartbeatTimer) {
  216. clearInterval(heartbeatTimer);
  217. heartbeatTimer = null;
  218. }
  219. };
  220. // --- 辅助:获取周期对应的毫秒数 ---
  221. const getPeriodMs = (period) => {
  222. const map = {
  223. '1m': 60 * 1000,
  224. '5m': 5 * 60 * 1000,
  225. '15m': 15 * 60 * 1000,
  226. '30m': 30 * 60 * 1000,
  227. '1h': 60 * 60 * 1000,
  228. '4h': 4 * 60 * 60 * 1000,
  229. '6h': 6 * 60 * 60 * 1000,
  230. '1d': 24 * 60 * 60 * 1000,
  231. '1w': 7 * 24 * 60 * 60 * 1000,
  232. '1M': 30 * 24 * 60 * 60 * 1000
  233. };
  234. return map[period] || 60 * 60 * 1000;
  235. }
  236. // --- 4. 核心:处理实时消息 (24hrTicker) ---
  237. const handleSocketMessage = (msgStr) => {
  238. try {
  239. // 忽略心跳响应
  240. if (msgStr === 'pong') return;
  241. const rawData = JSON.parse(msgStr);
  242. const msg = rawData.data || rawData;
  243. if (msg.e === "24hrTicker") {
  244. marketInfo.value = {
  245. price: msg.c, change: parseFloat(msg.P), high: msg.h, low: msg.l, vol: msg.v, amount: msg.q, fiatPrice: msg.c
  246. };
  247. if (kLineData.value.length > 0) {
  248. const lastIndex = kLineData.value.length - 1;
  249. const lastBar = kLineData.value[lastIndex];
  250. const newPrice = parseFloat(msg.c);
  251. const currentTime = Number(msg.E); // 事件时间
  252. const periodMs = getPeriodMs(currentTab.value);
  253. // 标准时间戳对齐算法: (当前时间 / 周期) * 周期
  254. const currentBarStart = Math.floor(currentTime / periodMs) * periodMs;
  255. // 如果计算出的起始时间 > 最后一根的起始时间,说明跨周期了,生成新 K 线
  256. if (currentBarStart > lastBar.timestamp) {
  257. const newBar = {
  258. timestamp: currentBarStart,
  259. open: newPrice,
  260. high: newPrice,
  261. low: newPrice,
  262. close: newPrice,
  263. volume: 0
  264. };
  265. // 扩展运算符触发更新
  266. kLineData.value = [...kLineData.value, newBar];
  267. } else {
  268. // 还在当前周期内,更新最后一根
  269. const updatedBar = {
  270. ...lastBar,
  271. close: newPrice,
  272. high: Math.max(lastBar.high, newPrice),
  273. low: Math.min(lastBar.low, newPrice)
  274. };
  275. kLineData.value.splice(lastIndex, 1, updatedBar);
  276. }
  277. }
  278. }
  279. } catch (e) {}
  280. };
  281. // --- 页面可见性监听 (切屏回来自动重连) ---
  282. const handleVisibilityChange = () => {
  283. if (document.visibilityState === 'visible') {
  284. if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
  285. console.log('👀 Page Visible, reconnecting...');
  286. connectWebSocket();
  287. }
  288. }
  289. };
  290. onMounted(() => {
  291. isUnmounted = false;
  292. getKlineData();
  293. connectWebSocket();
  294. document.addEventListener('visibilitychange', handleVisibilityChange);
  295. });
  296. onBeforeUnmount(() => {
  297. isUnmounted = true;
  298. closeWebSocket();
  299. document.removeEventListener('visibilitychange', handleVisibilityChange);
  300. });
  301. watch(symbolId, () => {
  302. kLineData.value = [];
  303. getKlineData();
  304. connectWebSocket();
  305. }, { immediate: false });
  306. const messageChange = (key) => { current.value = key; };
  307. const formatNumber = (num) => {
  308. if (!num) return '0.00';
  309. const n = Number(num);
  310. return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 });
  311. };
  312. const abbreviateNumber = (value) => {
  313. if (!value) return '0.00';
  314. let num = parseFloat(value);
  315. if (isNaN(num)) return '0.00';
  316. if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
  317. if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
  318. if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
  319. return num.toFixed(2);
  320. };
  321. const getPricePrecision = (price) => price < 1 ? 6 : (price < 10 ? 4 : 2);
  322. const getPriceColor = (change) => change >= 0 ? 'fc45B26B' : 'fcF6465D';
  323. const getUpDownClass = (change) => change >= 0 ? 'fc45B26B' : 'fcF6465D';
  324. </script>
  325. <style lang="less" scoped>
  326. .fc45B26B { color: #2EBD85 !important; }
  327. .fcF6465D { color: #F6465D !important; }
  328. .fc1F2937 { color: #1F2937; }
  329. /* 保持之前的样式布局 */
  330. .time-tabs {
  331. display: flex;
  332. align-items: center;
  333. justify-content: space-between;
  334. width: 100%;
  335. box-sizing: border-box;
  336. padding-top: 10px;
  337. padding-bottom: 0px;
  338. padding-left: 15px;
  339. padding-right: 15px;
  340. }
  341. .tab-item {
  342. font-size: 14px;
  343. color: #929AA5;
  344. padding: 4px 10px;
  345. border-radius: 6px;
  346. cursor: pointer;
  347. font-weight: 500;
  348. transition: all 0.2s;
  349. display: flex;
  350. align-items: center;
  351. justify-content: center;
  352. }
  353. .tab-item.icon {
  354. color: #929AA5;
  355. font-size: 12px;
  356. padding: 4px 4px;
  357. }
  358. .triangle {
  359. font-size: 8px;
  360. margin-left: 2px;
  361. transform: scale(0.9);
  362. }
  363. .tab-item img {
  364. display: block;
  365. height: 16px;
  366. width: auto;
  367. }
  368. .market-conditions {
  369. display: flex;
  370. flex-direction: column;
  371. justify-content: flex-start;
  372. align-items: center;
  373. width: 100%;
  374. .market-price {
  375. display: flex;
  376. flex-direction: row;
  377. justify-content: space-between;
  378. margin-top: 8px;
  379. width: 100%;
  380. height: 73px;
  381. padding: 0 15px;
  382. box-sizing: border-box;
  383. .price-left {
  384. display: flex;
  385. flex-direction: column;
  386. justify-content: flex-start;
  387. width: 144px;
  388. height: 69px;
  389. .left-price {
  390. display: flex;
  391. flex-direction: row;
  392. justify-content: flex-start;
  393. align-items: center;
  394. height: 18px;
  395. img {
  396. margin-left: 5px;
  397. width: 8px;
  398. height: 4px;
  399. }
  400. }
  401. .left-number {
  402. margin-top: 5px;
  403. }
  404. .left-appro {
  405. display: flex;
  406. flex-direction: row;
  407. justify-content: flex-start;
  408. align-items: center;
  409. margin-top: 3px;
  410. .appro {
  411. margin-left: 9px;
  412. }
  413. }
  414. }
  415. .price-right {
  416. display: flex;
  417. flex-direction: column;
  418. justify-content: flex-start;
  419. height: 100%;
  420. .right-number-top, .right-number-bottom {
  421. display: flex;
  422. flex-direction: row;
  423. justify-content: flex-end;
  424. width: 100%;
  425. height: 32px;
  426. .right-number-top-price, .right-number-top-number {
  427. margin-left: 10px;
  428. text-align: right;
  429. div {
  430. height: 16px;
  431. line-height: 16px;
  432. text-align: end;
  433. }
  434. }
  435. }
  436. .right-number-bottom {
  437. margin-top: 9px;
  438. }
  439. }
  440. }
  441. .k-line-main {
  442. height: 50vh;
  443. min-height: 350px;
  444. width: 100%;
  445. padding: 0 15px;
  446. }
  447. .notifi-classifi {
  448. display: flex;
  449. flex-direction: row;
  450. justify-content: flex-start;
  451. align-items: flex-end;
  452. margin-top: 15px;
  453. width: 100%;
  454. padding: 0 15px;
  455. box-sizing: border-box;
  456. height: 24px;
  457. .sys-notifi {
  458. margin-left: 47px;
  459. }
  460. }
  461. }
  462. </style>