MarketConditions.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. <template>
  2. <div class="market-conditions" @click="closePopups">
  3. <!-- 1. 头部行情 -->
  4. <div class="market-price">
  5. <div class="price-left">
  6. <div class="left-price pf400 fs14 fc333333">实时价格</div>
  7. <div class="left-number pf600 fs20 fc1F2937" :class="getPriceColor(marketInfo.change)">
  8. {{ formatNumber(marketInfo.price) }}
  9. </div>
  10. <div class="left-appro pf500 fs14 fcA8A8A8">
  11. ≈{{ formatNumber(marketInfo.fiatPrice) }}
  12. <span class="appro pf500 fs14" :class="getUpDownClass(marketInfo.change)">
  13. {{ marketInfo.change > 0 ? "+" : "" }}{{ marketInfo.change }}%
  14. </span>
  15. </div>
  16. </div>
  17. <div class="price-right">
  18. <div class="right-number-top">
  19. <div class="right-number-top-price">
  20. <div class="pf400 fs10 fcA8A8A8">24h 最高价</div>
  21. <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.high) }}</div>
  22. </div>
  23. <div class="right-number-top-number">
  24. <div class="pf400 fs10 fcA8A8A8">24h 成交量</div>
  25. <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.vol) }}</div>
  26. </div>
  27. </div>
  28. <div class="right-number-bottom">
  29. <div class="right-number-top-price">
  30. <div class="pf400 fs10 fcA8A8A8">24h 最低价</div>
  31. <div class="pf400 fs10 fc2C3131">{{ formatNumber(marketInfo.low) }}</div>
  32. </div>
  33. <div class="right-number-top-number">
  34. <div class="pf400 fs10 fcA8A8A8">24h 成交额</div>
  35. <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.amount) }}</div>
  36. </div>
  37. </div>
  38. </div>
  39. </div>
  40. <!-- 2. 周期切换 Tab -->
  41. <nav class="time-tabs">
  42. <!-- 常用周期 -->
  43. <div
  44. v-for="tab in visibleTabs"
  45. :key="tab"
  46. class="tab-item"
  47. :style="currentTab === tab ? { backgroundColor: '#F6465D', color: '#fff' } : {}"
  48. @click.stop="switchPeriod(tab)">
  49. {{ getTabLabel(tab) }}
  50. </div>
  51. <!-- 更多按钮 (下拉菜单) -->
  52. <div class="tab-item icon-btn" @click.stop="toggleMore">
  53. <span :class="{ 'active-text': isMoreActive }">更多</span>
  54. <span class="triangle">◢</span>
  55. <!-- 下拉菜单 -->
  56. <div class="dropdown-menu" v-show="showMoreMenu">
  57. <div
  58. v-for="mt in moreTabs"
  59. :key="mt"
  60. class="drop-item"
  61. :class="{ active: currentTab === mt }"
  62. @click.stop="switchPeriod(mt)">
  63. {{ getTabLabel(mt) }}
  64. </div>
  65. </div>
  66. </div>
  67. <!-- 指标按钮 -->
  68. <div class="tab-item icon-btn" @click.stop="toggleIndicators">
  69. <img src="@/assets/icon/bitcoin/lishidingdan.svg" alt="" />
  70. </div>
  71. </nav>
  72. <!-- 3. 指标设置面板 (点击指标图标弹出) -->
  73. <div class="indicator-panel" v-show="showIndicatorMenu" @click.stop>
  74. <div class="panel-section">
  75. <div class="section-title">主图</div>
  76. <div class="btn-group">
  77. <div class="idx-btn" :class="{ active: mainIdx === 'MA' }" @click="changeMain('MA')">MA</div>
  78. <div class="idx-btn" :class="{ active: mainIdx === 'BOLL' }" @click="changeMain('BOLL')">BOLL</div>
  79. <div class="idx-btn" :class="{ active: mainIdx === 'Hide' }" @click="changeMain('Hide')">隐藏</div>
  80. </div>
  81. </div>
  82. <div class="panel-section">
  83. <div class="section-title">副图</div>
  84. <div class="btn-group">
  85. <div class="idx-btn" :class="{ active: subIdx === 'VOL' }" @click="changeSub('VOL')">VOL</div>
  86. <div class="idx-btn" :class="{ active: subIdx === 'MACD' }" @click="changeSub('MACD')">MACD</div>
  87. <div class="idx-btn" :class="{ active: subIdx === 'KDJ' }" @click="changeSub('KDJ')">KDJ</div>
  88. <div class="idx-btn" :class="{ active: subIdx === 'RSI' }" @click="changeSub('RSI')">RSI</div>
  89. <div class="idx-btn" :class="{ active: subIdx === 'WR' }" @click="changeSub('WR')">WR</div>
  90. <div class="idx-btn" :class="{ active: subIdx === 'Hide' }" @click="changeSub('Hide')">隐藏</div>
  91. </div>
  92. </div>
  93. </div>
  94. <!-- 4. K线图组件 -->
  95. <div class="k-line-main">
  96. <KlineChart
  97. ref="klineRef"
  98. :data="kLineData"
  99. height="100%"
  100. :precision="{ price: getPricePrecision(marketInfo.price), volume: 2 }" />
  101. </div>
  102. <!-- 5. 底部挂单/成交 -->
  103. <div class="notifi-classifi">
  104. <div class="pf600 fs14" :class="current === 'entrustingOrder' ? 'fc121212' : 'fcA8A8A8'" @click="messageChange('entrustingOrder')">委托挂单</div>
  105. <div class="sys-notifi pf600 fs14" :class="current === 'latestTransactions' ? 'fc121212' : 'fcA8A8A8'" @click="messageChange('latestTransactions')">最新成交</div>
  106. </div>
  107. <component :is="currentComponent" :symbol-id="symbolId" :latestTransactionData="latestTransactionData" :orderPlacement="orderPlacement" />
  108. </div>
  109. </template>
  110. <script setup>
  111. import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
  112. import { useRoute } from "vue-router";
  113. import { GetCandlestickChart } from "@/api/index.js";
  114. import EntrustingOrder from "./EntrustingOrder.vue";
  115. import LatestTransactions from "./LatestTransactions.vue";
  116. import KlineChart from "@/views/bitcoin/lever/components/KLineChart.vue";
  117. const route = useRoute();
  118. const symbolId = computed(() => route.query.id || "6");
  119. // --- 周期设置 ---
  120. const currentTab = ref("1d");
  121. // 外面显示的周期
  122. const visibleTabs = ["15m", "1h", "4h", "1d"];
  123. // 下拉菜单里的周期
  124. const moreTabs = ["1m", "5m", "30m", "1w", "1M"];
  125. const isMoreActive = computed(() => moreTabs.includes(currentTab.value));
  126. const getTabLabel = (t) => {
  127. const map = { '1m':'1分', '5m':'5分', '15m':'15分', '30m':'30分', '1h':'1小时', '4h':'4小时', '1d':'日线', '1w':'周线', '1M':'月线' };
  128. return map[t] || t;
  129. };
  130. // --- UI 状态 ---
  131. const showMoreMenu = ref(false);
  132. const showIndicatorMenu = ref(false);
  133. const mainIdx = ref('MA');
  134. const subIdx = ref('VOL');
  135. const klineRef = ref(null); // 引用子组件
  136. // --- 交互方法 ---
  137. const toggleMore = () => {
  138. showIndicatorMenu.value = false;
  139. showMoreMenu.value = !showMoreMenu.value;
  140. };
  141. const toggleIndicators = () => {
  142. showMoreMenu.value = false;
  143. showIndicatorMenu.value = !showIndicatorMenu.value;
  144. };
  145. const closePopups = () => {
  146. showMoreMenu.value = false;
  147. showIndicatorMenu.value = false;
  148. };
  149. const switchPeriod = (period) => {
  150. if (currentTab.value === period) return;
  151. currentTab.value = period;
  152. showMoreMenu.value = false;
  153. kLineData.value = [];
  154. getKlineData();
  155. };
  156. const changeMain = (name) => {
  157. mainIdx.value = name;
  158. if (klineRef.value) klineRef.value.setMainIndicator(name);
  159. };
  160. const changeSub = (name) => {
  161. subIdx.value = name;
  162. if (klineRef.value) klineRef.value.setSubIndicator(name);
  163. };
  164. // --- 下面是 WebSocket 和数据逻辑 (保持你原有的逻辑) ---
  165. const kLineData = ref([]);
  166. const socket = ref(null);
  167. const WS_BASE_URL = "ws://backend.66linknow.com/ws/kline/";
  168. const HEARTBEAT_INTERVAL = 15000;
  169. const RECONNECT_DELAY = 3000;
  170. let heartbeatTimer = null;
  171. let reconnectTimer = null;
  172. let isUnmounted = false;
  173. const marketInfo = ref({ price: "0.00", fiatPrice: "0.00", change: 0.0, high: "0.00", low: "0.00", vol: "0", amount: "0" });
  174. const orderPlacement = ref();
  175. const latestTransactionData = ref();
  176. const current = ref("entrustingOrder");
  177. const componentsMap = { entrustingOrder: EntrustingOrder, latestTransactions: LatestTransactions };
  178. const currentComponent = computed(() => componentsMap[current.value]);
  179. const getKlineData = async () => {
  180. if (typeof GetCandlestickChart !== "function") return;
  181. try {
  182. const res = await GetCandlestickChart({ symbol: symbolId.value, period: currentTab.value });
  183. let rawList = Array.isArray(res) ? res : (res && res.data ? res.data : []);
  184. if (rawList.length > 0) {
  185. const formattedData = rawList.map((item) => ({
  186. timestamp: Number(item[0]), open: parseFloat(item[1]), high: parseFloat(item[2]),
  187. low: parseFloat(item[3]), close: parseFloat(item[4]), volume: parseFloat(item[5])
  188. }));
  189. formattedData.sort((a, b) => a.timestamp - b.timestamp);
  190. kLineData.value = formattedData;
  191. if (marketInfo.value.price === "0.00") {
  192. const lastBar = formattedData[formattedData.length - 1];
  193. let totalVol = 0;
  194. formattedData.forEach(i => totalVol += i.volume);
  195. marketInfo.value = {
  196. price: lastBar.close, fiatPrice: lastBar.close, change: 0,
  197. high: lastBar.high, low: lastBar.low, vol: totalVol, amount: totalVol * lastBar.close
  198. };
  199. }
  200. }
  201. } catch (error) { console.error("API Error", error); }
  202. };
  203. const connectWebSocket = () => {
  204. closeWebSocket();
  205. const symbolStr = String(route.query.type || "btcusdt").toLowerCase();
  206. const url = `${WS_BASE_URL}?symbol=${symbolStr}`;
  207. try {
  208. socket.value = new WebSocket(url);
  209. socket.value.onopen = () => { startHeartbeat(); };
  210. socket.value.onmessage = (event) => { handleSocketMessage(event.data); };
  211. socket.value.onclose = () => {
  212. stopHeartbeat();
  213. if (!isUnmounted) {
  214. clearTimeout(reconnectTimer);
  215. reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
  216. }
  217. };
  218. } catch (e) { if (!isUnmounted) reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY); }
  219. };
  220. const closeWebSocket = () => { if (socket.value) { socket.value.close(); socket.value = null; } stopHeartbeat(); clearTimeout(reconnectTimer); };
  221. const startHeartbeat = () => { stopHeartbeat(); heartbeatTimer = setInterval(() => { socket.value?.readyState === 1 && socket.value.send("ping"); }, HEARTBEAT_INTERVAL); };
  222. const stopHeartbeat = () => { clearInterval(heartbeatTimer); heartbeatTimer = null; };
  223. const handleSocketMessage = (msgStr) => {
  224. try {
  225. if (msgStr === "pong") return;
  226. const rawData = JSON.parse(msgStr);
  227. const msg = rawData.data || rawData;
  228. if (msg.e === "kline") {
  229. const k = msg.k;
  230. if (k.i !== currentTab.value) return;
  231. 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) };
  232. updateKlineData(newBar);
  233. marketInfo.value.price = newBar.close;
  234. marketInfo.value.fiatPrice = newBar.close;
  235. } else if (msg.e === "24hrTicker") {
  236. marketInfo.value.change = parseFloat(msg.P).toFixed(2);
  237. marketInfo.value.high = msg.h; marketInfo.value.low = msg.l;
  238. marketInfo.value.vol = msg.v; marketInfo.value.amount = msg.q;
  239. } else if (rawData.stream?.includes("@depth20")) orderPlacement.value = rawData.data;
  240. else if (rawData.stream?.includes("@aggTrade")) latestTransactionData.value = rawData.data;
  241. } catch (e) {}
  242. };
  243. const updateKlineData = (newBar) => {
  244. if (!kLineData.value?.length) return;
  245. const lastIndex = kLineData.value.length - 1;
  246. const lastBar = kLineData.value[lastIndex];
  247. if (newBar.timestamp === lastBar.timestamp) kLineData.value.splice(lastIndex, 1, newBar);
  248. else if (newBar.timestamp > lastBar.timestamp) kLineData.value.push(newBar);
  249. };
  250. const handleVisibilityChange = () => { if (document.visibilityState === "visible" && socket.value?.readyState !== 1) connectWebSocket(); };
  251. onMounted(() => { isUnmounted = false; getKlineData(); connectWebSocket(); document.addEventListener("visibilitychange", handleVisibilityChange); });
  252. onBeforeUnmount(() => { isUnmounted = true; closeWebSocket(); document.removeEventListener("visibilitychange", handleVisibilityChange); });
  253. watch(symbolId, () => { kLineData.value = []; getKlineData(); connectWebSocket(); });
  254. const messageChange = (key) => current.value = key;
  255. const formatNumber = (num) => num ? Number(num).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 }) : "0.00";
  256. 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); };
  257. const getPricePrecision = (p) => (p < 1 ? 6 : p < 10 ? 4 : 2);
  258. const getPriceColor = (c) => (c >= 0 ? "fc45B26B" : "fcF6465D");
  259. const getUpDownClass = (c) => (c >= 0 ? "fc45B26B" : "fcF6465D");
  260. </script>
  261. <style lang="less" scoped>
  262. .fc45B26B { color: #2ebd85 !important; }
  263. .fcF6465D { color: #f6465d !important; }
  264. .fc1F2937 { color: #1f2937; }
  265. /* 周期切换栏 */
  266. .time-tabs {
  267. display: flex;
  268. align-items: center;
  269. justify-content: space-between;
  270. width: 100%;
  271. box-sizing: border-box;
  272. padding: 10px 15px 0 15px;
  273. position: relative;
  274. z-index: 20;
  275. }
  276. .tab-item {
  277. font-size: 14px;
  278. color: #929aa5;
  279. padding: 4px 8px;
  280. border-radius: 4px;
  281. cursor: pointer;
  282. font-weight: 500;
  283. display: flex;
  284. align-items: center;
  285. position: relative;
  286. }
  287. .tab-item.icon-btn { padding: 4px 4px; }
  288. .active-text { color: #1F2937; font-weight: 600; }
  289. .triangle { font-size: 8px; margin-left: 2px; transform: scale(0.9); }
  290. .tab-item img { display: block; height: 16px; width: auto; }
  291. /* 更多周期下拉菜单 */
  292. .dropdown-menu {
  293. position: absolute;
  294. top: 30px;
  295. left: -20px;
  296. width: 80px;
  297. background: #fff;
  298. box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  299. border-radius: 6px;
  300. padding: 5px 0;
  301. z-index: 100;
  302. display: flex;
  303. flex-direction: column;
  304. }
  305. .drop-item {
  306. padding: 8px 15px;
  307. font-size: 13px;
  308. color: #666;
  309. text-align: center;
  310. }
  311. .drop-item.active { color: #F6465D; background: #fff5f5; }
  312. /* 指标面板 */
  313. .indicator-panel {
  314. position: absolute;
  315. top: 155px; /* 调整此值以对齐 K 线图顶部 */
  316. left: 15px;
  317. right: 15px;
  318. background: #fff;
  319. box-shadow: 0 4px 15px rgba(0,0,0,0.1);
  320. border-radius: 8px;
  321. padding: 15px;
  322. z-index: 99;
  323. }
  324. .panel-section { margin-bottom: 15px; }
  325. .section-title { font-size: 12px; color: #999; margin-bottom: 8px; }
  326. .btn-group { display: flex; flex-wrap: wrap; gap: 10px; }
  327. .idx-btn {
  328. padding: 4px 12px; border: 1px solid #eee; border-radius: 14px; font-size: 12px; color: #666; cursor: pointer;
  329. }
  330. .idx-btn.active { border-color: #F6465D; color: #F6465D; background-color: #fff5f5; }
  331. /* 容器布局 */
  332. .market-conditions {
  333. display: flex; flex-direction: column; align-items: center; margin-bottom: 50px; width: 100%; position: relative;
  334. }
  335. .market-price {
  336. display: flex; justify-content: space-between; margin-top: 8px; width: 100%; height: 73px; padding: 0 15px; box-sizing: border-box;
  337. .price-left {
  338. display: flex; flex-direction: column; width: 144px; height: 69px;
  339. .left-price { display: flex; align-items: center; height: 18px; }
  340. .left-number { margin-top: 5px; }
  341. .left-appro { display: flex; align-items: center; margin-top: 3px; .appro { margin-left: 9px; } }
  342. }
  343. .price-right {
  344. display: flex; flex-direction: column; height: 100%;
  345. .right-number-top, .right-number-bottom {
  346. display: flex; justify-content: flex-end; width: 100%; height: 32px;
  347. .right-number-top-price, .right-number-top-number { margin-left: 10px; text-align: right; div { height: 16px; line-height: 16px; text-align: end; } }
  348. }
  349. .right-number-bottom { margin-top: 9px; }
  350. }
  351. }
  352. .k-line-main { height: 50vh; min-height: 350px; width: 100%; padding: 0 15px; margin-top: 10px; }
  353. .notifi-classifi {
  354. 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;
  355. .sys-notifi { margin-left: 47px; }
  356. }
  357. </style>