| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- <template>
- <div class="market-conditions" @click="closePopups">
- <!-- 1. 头部行情 -->
- <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>
- <!-- 2. 周期切换 Tab -->
- <nav class="time-tabs">
- <!-- 常用周期 -->
- <div
- v-for="tab in visibleTabs"
- :key="tab"
- class="tab-item"
- :style="currentTab === tab ? { backgroundColor: '#F6465D', color: '#fff' } : {}"
- @click.stop="switchPeriod(tab)">
- {{ getTabLabel(tab) }}
- </div>
- <!-- 更多按钮 (下拉菜单) -->
- <div class="tab-item icon-btn" @click.stop="toggleMore">
- <span :class="{ 'active-text': isMoreActive }">更多</span>
- <span class="triangle">◢</span>
- <!-- 下拉菜单 -->
- <div class="dropdown-menu" v-show="showMoreMenu">
- <div
- v-for="mt in moreTabs"
- :key="mt"
- class="drop-item"
- :class="{ active: currentTab === mt }"
- @click.stop="switchPeriod(mt)">
- {{ getTabLabel(mt) }}
- </div>
- </div>
- </div>
- <!-- 指标按钮 -->
- <div class="tab-item icon-btn" @click.stop="toggleIndicators">
- <img src="@/assets/icon/bitcoin/lishidingdan.svg" alt="" />
- </div>
- </nav>
- <!-- 3. 指标设置面板 (点击指标图标弹出) -->
- <div class="indicator-panel" v-show="showIndicatorMenu" @click.stop>
- <div class="panel-section">
- <div class="section-title">主图</div>
- <div class="btn-group">
- <div class="idx-btn" :class="{ active: mainIdx === 'MA' }" @click="changeMain('MA')">MA</div>
- <div class="idx-btn" :class="{ active: mainIdx === 'BOLL' }" @click="changeMain('BOLL')">BOLL</div>
- <div class="idx-btn" :class="{ active: mainIdx === 'Hide' }" @click="changeMain('Hide')">隐藏</div>
- </div>
- </div>
- <div class="panel-section">
- <div class="section-title">副图</div>
- <div class="btn-group">
- <div class="idx-btn" :class="{ active: subIdx === 'VOL' }" @click="changeSub('VOL')">VOL</div>
- <div class="idx-btn" :class="{ active: subIdx === 'MACD' }" @click="changeSub('MACD')">MACD</div>
- <div class="idx-btn" :class="{ active: subIdx === 'KDJ' }" @click="changeSub('KDJ')">KDJ</div>
- <div class="idx-btn" :class="{ active: subIdx === 'RSI' }" @click="changeSub('RSI')">RSI</div>
- <div class="idx-btn" :class="{ active: subIdx === 'WR' }" @click="changeSub('WR')">WR</div>
- <div class="idx-btn" :class="{ active: subIdx === 'Hide' }" @click="changeSub('Hide')">隐藏</div>
- </div>
- </div>
- </div>
- <!-- 4. K线图组件 -->
- <div class="k-line-main">
- <KlineChart
- ref="klineRef"
- :data="kLineData"
- height="100%"
- :precision="{ price: getPricePrecision(marketInfo.price), volume: 2 }" />
- </div>
- <!-- 5. 底部挂单/成交 -->
- <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, onBeforeUnmount, watch } 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 visibleTabs = ["15m", "1h", "4h", "1d"];
- // 下拉菜单里的周期
- const moreTabs = ["1m", "5m", "30m", "1w", "1M"];
- const isMoreActive = computed(() => moreTabs.includes(currentTab.value));
- const getTabLabel = (t) => {
- const map = { '1m':'1分', '5m':'5分', '15m':'15分', '30m':'30分', '1h':'1小时', '4h':'4小时', '1d':'日线', '1w':'周线', '1M':'月线' };
- return map[t] || t;
- };
- // --- UI 状态 ---
- const showMoreMenu = ref(false);
- const showIndicatorMenu = ref(false);
- const mainIdx = ref('MA');
- const subIdx = ref('VOL');
- const klineRef = ref(null); // 引用子组件
- // --- 交互方法 ---
- const toggleMore = () => {
- showIndicatorMenu.value = false;
- showMoreMenu.value = !showMoreMenu.value;
- };
- const toggleIndicators = () => {
- showMoreMenu.value = false;
- showIndicatorMenu.value = !showIndicatorMenu.value;
- };
- const closePopups = () => {
- showMoreMenu.value = false;
- showIndicatorMenu.value = false;
- };
- const switchPeriod = (period) => {
- if (currentTab.value === period) return;
- currentTab.value = period;
- showMoreMenu.value = false;
- kLineData.value = [];
- getKlineData();
- };
- const changeMain = (name) => {
- mainIdx.value = name;
- if (klineRef.value) klineRef.value.setMainIndicator(name);
- };
- const changeSub = (name) => {
- subIdx.value = name;
- if (klineRef.value) klineRef.value.setSubIndicator(name);
- };
- // --- 下面是 WebSocket 和数据逻辑 (保持你原有的逻辑) ---
- const kLineData = ref([]);
- const socket = ref(null);
- const WS_BASE_URL = "ws://backend.66linknow.com/ws/kline/";
- const HEARTBEAT_INTERVAL = 15000;
- const RECONNECT_DELAY = 3000;
- 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]);
- const getKlineData = async () => {
- if (typeof GetCandlestickChart !== "function") return;
- try {
- const res = await GetCandlestickChart({ symbol: symbolId.value, period: currentTab.value });
- let rawList = Array.isArray(res) ? res : (res && res.data ? 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;
- if (marketInfo.value.price === "0.00") {
- const lastBar = formattedData[formattedData.length - 1];
- let totalVol = 0;
- formattedData.forEach(i => totalVol += i.volume);
- marketInfo.value = {
- price: lastBar.close, fiatPrice: lastBar.close, change: 0,
- high: lastBar.high, low: lastBar.low, vol: totalVol, amount: totalVol * lastBar.close
- };
- }
- }
- } catch (error) { console.error("API Error", error); }
- };
- const connectWebSocket = () => {
- closeWebSocket();
- const symbolStr = String(route.query.type || "btcusdt").toLowerCase();
- const url = `${WS_BASE_URL}?symbol=${symbolStr}`;
- try {
- socket.value = new WebSocket(url);
- socket.value.onopen = () => { startHeartbeat(); };
- socket.value.onmessage = (event) => { handleSocketMessage(event.data); };
- socket.value.onclose = () => {
- stopHeartbeat();
- if (!isUnmounted) {
- clearTimeout(reconnectTimer);
- reconnectTimer = setTimeout(() => connectWebSocket(), RECONNECT_DELAY);
- }
- };
- } 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(() => { socket.value?.readyState === 1 && socket.value.send("ping"); }, HEARTBEAT_INTERVAL); };
- const stopHeartbeat = () => { clearInterval(heartbeatTimer); heartbeatTimer = null; };
- const handleSocketMessage = (msgStr) => {
- try {
- if (msgStr === "pong") return;
- const rawData = JSON.parse(msgStr);
- const msg = rawData.data || rawData;
- if (msg.e === "kline") {
- const k = msg.k;
- if (k.i !== currentTab.value) return;
- 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) };
- updateKlineData(newBar);
- marketInfo.value.price = newBar.close;
- marketInfo.value.fiatPrice = newBar.close;
- } else if (msg.e === "24hrTicker") {
- marketInfo.value.change = parseFloat(msg.P).toFixed(2);
- marketInfo.value.high = msg.h; marketInfo.value.low = msg.l;
- marketInfo.value.vol = msg.v; marketInfo.value.amount = msg.q;
- } else if (rawData.stream?.includes("@depth20")) orderPlacement.value = rawData.data;
- else if (rawData.stream?.includes("@aggTrade")) latestTransactionData.value = rawData.data;
- } catch (e) {}
- };
- const updateKlineData = (newBar) => {
- if (!kLineData.value?.length) return;
- const lastIndex = kLineData.value.length - 1;
- const lastBar = kLineData.value[lastIndex];
- if (newBar.timestamp === lastBar.timestamp) kLineData.value.splice(lastIndex, 1, newBar);
- else if (newBar.timestamp > lastBar.timestamp) kLineData.value.push(newBar);
- };
- const handleVisibilityChange = () => { if (document.visibilityState === "visible" && socket.value?.readyState !== 1) connectWebSocket(); };
- onMounted(() => { isUnmounted = false; getKlineData(); connectWebSocket(); document.addEventListener("visibilitychange", handleVisibilityChange); });
- onBeforeUnmount(() => { isUnmounted = true; closeWebSocket(); document.removeEventListener("visibilitychange", handleVisibilityChange); });
- watch(symbolId, () => { kLineData.value = []; getKlineData(); connectWebSocket(); });
- const messageChange = (key) => current.value = key;
- const formatNumber = (num) => num ? Number(num).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 }) : "0.00";
- 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); };
- const getPricePrecision = (p) => (p < 1 ? 6 : p < 10 ? 4 : 2);
- const getPriceColor = (c) => (c >= 0 ? "fc45B26B" : "fcF6465D");
- const getUpDownClass = (c) => (c >= 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: 10px 15px 0 15px;
- position: relative;
- z-index: 20;
- }
- .tab-item {
- font-size: 14px;
- color: #929aa5;
- padding: 4px 8px;
- border-radius: 4px;
- cursor: pointer;
- font-weight: 500;
- display: flex;
- align-items: center;
- position: relative;
- }
- .tab-item.icon-btn { padding: 4px 4px; }
- .active-text { color: #1F2937; font-weight: 600; }
- .triangle { font-size: 8px; margin-left: 2px; transform: scale(0.9); }
- .tab-item img { display: block; height: 16px; width: auto; }
- /* 更多周期下拉菜单 */
- .dropdown-menu {
- position: absolute;
- top: 30px;
- left: -20px;
- width: 80px;
- background: #fff;
- box-shadow: 0 4px 12px rgba(0,0,0,0.15);
- border-radius: 6px;
- padding: 5px 0;
- z-index: 100;
- display: flex;
- flex-direction: column;
- }
- .drop-item {
- padding: 8px 15px;
- font-size: 13px;
- color: #666;
- text-align: center;
- }
- .drop-item.active { color: #F6465D; background: #fff5f5; }
- /* 指标面板 */
- .indicator-panel {
- position: absolute;
- top: 155px; /* 调整此值以对齐 K 线图顶部 */
- left: 15px;
- right: 15px;
- background: #fff;
- box-shadow: 0 4px 15px rgba(0,0,0,0.1);
- border-radius: 8px;
- padding: 15px;
- z-index: 99;
- }
- .panel-section { margin-bottom: 15px; }
- .section-title { font-size: 12px; color: #999; margin-bottom: 8px; }
- .btn-group { display: flex; flex-wrap: wrap; gap: 10px; }
- .idx-btn {
- padding: 4px 12px; border: 1px solid #eee; border-radius: 14px; font-size: 12px; color: #666; cursor: pointer;
- }
- .idx-btn.active { border-color: #F6465D; color: #F6465D; background-color: #fff5f5; }
- /* 容器布局 */
- .market-conditions {
- display: flex; flex-direction: column; align-items: center; margin-bottom: 50px; width: 100%; position: relative;
- }
- .market-price {
- display: flex; 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; width: 144px; height: 69px;
- .left-price { display: flex; align-items: center; height: 18px; }
- .left-number { margin-top: 5px; }
- .left-appro { display: flex; align-items: center; margin-top: 3px; .appro { margin-left: 9px; } }
- }
- .price-right {
- display: flex; flex-direction: column; height: 100%;
- .right-number-top, .right-number-bottom {
- display: flex; 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; margin-top: 10px; }
- .notifi-classifi {
- 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;
- .sys-notifi { margin-left: 47px; }
- }
- </style>
|