|
|
@@ -58,6 +58,2382 @@
|
|
|
</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"
|
|
|
+ :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);
|
|
|
+ }
|
|
|
+ } 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;
|
|
|
+ // 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");
|
|
|
+</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;
|
|
|
+ }
|
|
|
+
|
|
|
+ .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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|
|
|
+<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"
|
|
|
+ :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);
|
|
|
+ }
|
|
|
+ } 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;
|
|
|
+ // 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");
|
|
|
+</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;
|
|
|
+ }
|
|
|
+
|
|
|
+ .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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|
|
|
+<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"
|
|
|
+ :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);
|
|
|
+ }
|
|
|
+ } 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;
|
|
|
+ // 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");
|
|
|
+</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;
|
|
|
+ }
|
|
|
+
|
|
|
+ .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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|
|
|
+<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"
|
|
|
+ :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);
|
|
|
+ }
|
|
|
+ } 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;
|
|
|
+ // 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");
|
|
|
+</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;
|
|
|
+ }
|
|
|
+
|
|
|
+ .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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|
|
|
+<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"
|