|
|
@@ -4,7 +4,9 @@
|
|
|
<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)">
|
|
|
+ <div
|
|
|
+ class="left-number pf600 fs20 fc1F2937"
|
|
|
+ :class="getPriceColor(marketInfo.change)">
|
|
|
{{ formatNumber(marketInfo.price) }}
|
|
|
</div>
|
|
|
<div class="left-appro pf500 fs14 fcA8A8A8">
|
|
|
@@ -32,7 +34,9 @@
|
|
|
</div>
|
|
|
<div class="right-number-top-number">
|
|
|
<div class="pf400 fs10 fcA8A8A8">24h 成交额</div>
|
|
|
- <div class="pf400 fs10 fc2C3131">{{ abbreviateNumber(marketInfo.amount) }}</div>
|
|
|
+ <div class="pf400 fs10 fc2C3131">
|
|
|
+ {{ abbreviateNumber(marketInfo.amount) }}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -49,13 +53,20 @@
|
|
|
{{ getTabLabel(tab) }}
|
|
|
</div>
|
|
|
|
|
|
- <div class="tab-item icon-btn" @click.stop="toggleMore" :style="isMoreActive ? { backgroundColor: '#F6465D', color: '#fff' } : {}">
|
|
|
- <span :class="{ 'active-text': isMoreActive }">{{ isMoreActive ? getTabLabel(currentTab) : '更多' }}</span>
|
|
|
+ <div
|
|
|
+ class="tab-item icon-btn"
|
|
|
+ @click.stop="toggleMore"
|
|
|
+ :style="isMoreActive ? { backgroundColor: '#F6465D', color: '#fff' } : {}">
|
|
|
+ <span :class="{ 'active-text': isMoreActive }">{{
|
|
|
+ isMoreActive ? getTabLabel(currentTab) : "更多"
|
|
|
+ }}</span>
|
|
|
<span class="triangle" :style="isMoreActive ? { color: '#fff' } : {}">◢</span>
|
|
|
<div class="dropdown-menu" v-show="showMoreMenu">
|
|
|
<div
|
|
|
- v-for="mt in moreTabs" :key="mt"
|
|
|
- class="drop-item" :class="{ active: currentTab === mt }"
|
|
|
+ v-for="mt in moreTabs"
|
|
|
+ :key="mt"
|
|
|
+ class="drop-item"
|
|
|
+ :class="{ active: currentTab === mt }"
|
|
|
@click.stop="switchPeriod(mt)">
|
|
|
{{ getTabLabel(mt) }}
|
|
|
</div>
|
|
|
@@ -72,19 +83,36 @@
|
|
|
<div class="panel-section">
|
|
|
<div class="section-title">主图指标</div>
|
|
|
<div class="btn-group">
|
|
|
- <div v-for="m in ['MA', 'EMA', 'BOLL', 'SAR']" :key="m"
|
|
|
- class="idx-btn" :class="{ active: mainIdx === m }"
|
|
|
- @click="changeMain(m)">{{ m }}</div>
|
|
|
- <div class="idx-btn" :class="{ active: mainIdx === 'Hide' }" @click="changeMain('Hide')">隐藏</div>
|
|
|
+ <div
|
|
|
+ v-for="m in ['MA', 'EMA', 'BOLL', 'SAR']"
|
|
|
+ :key="m"
|
|
|
+ class="idx-btn"
|
|
|
+ :class="{ active: mainIdx === m }"
|
|
|
+ @click="changeMain(m)">
|
|
|
+ {{ m }}
|
|
|
+ </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 v-for="s in ['VOL', 'MACD', 'KDJ', 'RSI', 'WR', 'CCI', 'OBV']" :key="s"
|
|
|
- class="idx-btn" :class="{ active: subIdx.includes(s) }"
|
|
|
- @click="toggleSub(s)">{{ s }}</div>
|
|
|
- <div class="idx-btn" :class="{ active: subIdx.length === 0 }" @click="clearSub">隐藏</div>
|
|
|
+ <div
|
|
|
+ v-for="s in ['VOL', 'MACD', 'KDJ', 'RSI', 'WR', 'CCI', 'OBV']"
|
|
|
+ :key="s"
|
|
|
+ class="idx-btn"
|
|
|
+ :class="{ active: subIdx.includes(s) }"
|
|
|
+ @click="toggleSub(s)">
|
|
|
+ {{ s }}
|
|
|
+ </div>
|
|
|
+ <div class="idx-btn" :class="{ active: subIdx.length === 0 }" @click="clearSub">
|
|
|
+ 隐藏
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -101,263 +129,548 @@
|
|
|
|
|
|
<!-- 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
|
|
|
+ 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" />
|
|
|
+ <component
|
|
|
+ :is="currentComponent"
|
|
|
+ :symbol-id="symbolId"
|
|
|
+ :latestTransactionData="latestTransactionData"
|
|
|
+ :orderPlacement="orderPlacement" />
|
|
|
|
|
|
<div
|
|
|
+ style="
|
|
|
+ box-shadow: 0 -0.05333rem 0.26667rem rgba(109 109 109 / 5%);
|
|
|
+ box-sizing: border-box;
|
|
|
+ margin-top: 20px;
|
|
|
+ width: 100%;
|
|
|
+ z-index: 99;
|
|
|
+ position: sticky;
|
|
|
+ bottom: 0;
|
|
|
+ background-color: #ffffff;
|
|
|
+ padding: 20px;
|
|
|
+ ">
|
|
|
+ <div
|
|
|
+ @click="router.push('/bitcoin/CryptocurrencyTrading')"
|
|
|
style="
|
|
|
- box-shadow: 0 -0.05333rem 0.26667rem rgba(109 109 109 / 5%);
|
|
|
- box-sizing: border-box;margin-top: 20px;
|
|
|
- width:100%;z-index: 99;position: sticky; bottom: 0;background-color: #FFFFFF;padding: 20px;">
|
|
|
- <div @click="router.push('/bitcoin/CryptocurrencyTrading')"
|
|
|
- style="font-size: 17px; margin:0 auto;border-radius:19px;color:#FFFFFF; text-align:center; width:80%; line-height: 38px; font-weight: 500; background-color: #F6465D; ">交易</div>
|
|
|
+ font-size: 17px;
|
|
|
+ margin: 0 auto;
|
|
|
+ border-radius: 19px;
|
|
|
+ color: #ffffff;
|
|
|
+ text-align: center;
|
|
|
+ width: 80%;
|
|
|
+ line-height: 38px;
|
|
|
+ font-weight: 500;
|
|
|
+ background-color: #f6465d;
|
|
|
+ ">
|
|
|
+ 交易
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
|
|
-import { useRoute,useRouter } from "vue-router";
|
|
|
-import { GetCandlestickChart } from "@/api/index.js";
|
|
|
-
|
|
|
-const router = useRouter(); // 【新增】 实例化路由
|
|
|
-
|
|
|
-// 引入组件
|
|
|
-import KLineChart from "@/views/bitcoin/lever/components/KLineChart.vue";
|
|
|
-import EntrustingOrder from "./EntrustingOrder.vue";
|
|
|
-import LatestTransactions from "./LatestTransactions.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) => ({ '1m':'1分', '5m':'5分', '15m':'15分', '30m':'30分', '1h':'1小时', '4h':'4小时', '1d':'日线', '1w':'周线', '1M':'月线' }[t] || t);
|
|
|
-
|
|
|
-// --- 指标管理 ---
|
|
|
-const showMoreMenu = ref(false);
|
|
|
-const showIndicatorMenu = ref(false);
|
|
|
-const klineRef = ref(null);
|
|
|
-const mainIdx = ref('MA');
|
|
|
-const subIdx = ref(['VOL', 'MACD']);
|
|
|
-
|
|
|
-const changeMain = (name) => { mainIdx.value = name; if (klineRef.value) klineRef.value.setMainIndicator(name); };
|
|
|
-const toggleSub = (name) => {
|
|
|
- const index = subIdx.value.indexOf(name);
|
|
|
- if (index > -1) subIdx.value.splice(index, 1);
|
|
|
- else {
|
|
|
- if (subIdx.value.length >= 3) subIdx.value.shift();
|
|
|
- subIdx.value.push(name);
|
|
|
- }
|
|
|
- if (klineRef.value) klineRef.value.setSubIndicators(subIdx.value);
|
|
|
-};
|
|
|
-const clearSub = () => { subIdx.value = []; if (klineRef.value) klineRef.value.setSubIndicators([]); };
|
|
|
-
|
|
|
-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();
|
|
|
-};
|
|
|
-
|
|
|
-// --- 数据/WS ---
|
|
|
-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, reconnectTimer = null, isUnmounted = false;
|
|
|
-const isLoadingMore = ref(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 messageChange = (key) => current.value = key;
|
|
|
-
|
|
|
-// --- 获取 K线数据 (核心) ---
|
|
|
-const getKlineData = async (endTime = null) => {
|
|
|
- if (typeof GetCandlestickChart !== "function") return;
|
|
|
-
|
|
|
- try {
|
|
|
- const params = {
|
|
|
- symbol: symbolId.value,
|
|
|
- period: currentTab.value,
|
|
|
- limit: 150,
|
|
|
- to: endTime || undefined
|
|
|
- };
|
|
|
-
|
|
|
- const res = await GetCandlestickChart(params);
|
|
|
-
|
|
|
- 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);
|
|
|
-
|
|
|
- if (endTime) {
|
|
|
- // --- 加载历史模式 ---
|
|
|
- if (klineRef.value) {
|
|
|
- const hasMore = formattedData.length >= 150;
|
|
|
- // 核心点:子组件 KLineChart 内部现在会自动处理 overlap
|
|
|
- klineRef.value.applyHistoryData(formattedData, hasMore);
|
|
|
+ import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
|
|
+ import { useRoute, useRouter } from "vue-router";
|
|
|
+ import { GetCandlestickChart } from "@/api/index.js";
|
|
|
+
|
|
|
+ const router = useRouter(); // 【新增】 实例化路由
|
|
|
+
|
|
|
+ // 引入组件
|
|
|
+ import KLineChart from "@/views/bitcoin/lever/components/KLineChart.vue";
|
|
|
+ import EntrustingOrder from "./EntrustingOrder.vue";
|
|
|
+ import LatestTransactions from "./LatestTransactions.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) =>
|
|
|
+ ({
|
|
|
+ "1m": "1分",
|
|
|
+ "5m": "5分",
|
|
|
+ "15m": "15分",
|
|
|
+ "30m": "30分",
|
|
|
+ "1h": "1小时",
|
|
|
+ "4h": "4小时",
|
|
|
+ "1d": "日线",
|
|
|
+ "1w": "周线",
|
|
|
+ "1M": "月线",
|
|
|
+ }[t] || t);
|
|
|
+
|
|
|
+ // --- 指标管理 ---
|
|
|
+ const showMoreMenu = ref(false);
|
|
|
+ const showIndicatorMenu = ref(false);
|
|
|
+ const klineRef = ref(null);
|
|
|
+ const mainIdx = ref("MA");
|
|
|
+ const subIdx = ref(["VOL", "MACD"]);
|
|
|
+
|
|
|
+ const changeMain = (name) => {
|
|
|
+ mainIdx.value = name;
|
|
|
+ if (klineRef.value) klineRef.value.setMainIndicator(name);
|
|
|
+ };
|
|
|
+ const toggleSub = (name) => {
|
|
|
+ const index = subIdx.value.indexOf(name);
|
|
|
+ if (index > -1) subIdx.value.splice(index, 1);
|
|
|
+ else {
|
|
|
+ if (subIdx.value.length >= 3) subIdx.value.shift();
|
|
|
+ subIdx.value.push(name);
|
|
|
+ }
|
|
|
+ if (klineRef.value) klineRef.value.setSubIndicators(subIdx.value);
|
|
|
+ };
|
|
|
+ const clearSub = () => {
|
|
|
+ subIdx.value = [];
|
|
|
+ if (klineRef.value) klineRef.value.setSubIndicators([]);
|
|
|
+ };
|
|
|
+
|
|
|
+ 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();
|
|
|
+ };
|
|
|
+
|
|
|
+ // --- 数据/WS ---
|
|
|
+ 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,
|
|
|
+ reconnectTimer = null,
|
|
|
+ isUnmounted = false;
|
|
|
+ const isLoadingMore = ref(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 messageChange = (key) => (current.value = key);
|
|
|
+
|
|
|
+ // --- 获取 K线数据 (核心) ---
|
|
|
+ const getKlineData = async (endTime = null) => {
|
|
|
+ if (typeof GetCandlestickChart !== "function") return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const params = {
|
|
|
+ symbol: symbolId.value,
|
|
|
+ period: currentTab.value,
|
|
|
+ limit: 150,
|
|
|
+ to: endTime || undefined,
|
|
|
+ };
|
|
|
+
|
|
|
+ const res = await GetCandlestickChart(params);
|
|
|
+
|
|
|
+ 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);
|
|
|
+
|
|
|
+ if (endTime) {
|
|
|
+ // --- 加载历史模式 ---
|
|
|
+ if (klineRef.value) {
|
|
|
+ const hasMore = formattedData.length >= 150;
|
|
|
+ // 核心点:子组件 KLineChart 内部现在会自动处理 overlap
|
|
|
+ klineRef.value.applyHistoryData(formattedData, hasMore);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // --- 初始化/重置模式 ---
|
|
|
+ kLineData.value = formattedData;
|
|
|
+ if (formattedData.length > 0) {
|
|
|
+ const lastBar = formattedData[formattedData.length - 1];
|
|
|
+ marketInfo.value = {
|
|
|
+ ...marketInfo.value,
|
|
|
+ price: lastBar.close,
|
|
|
+ fiatPrice: lastBar.close,
|
|
|
+ high: lastBar.high,
|
|
|
+ low: lastBar.low,
|
|
|
+ };
|
|
|
+ }
|
|
|
}
|
|
|
} else {
|
|
|
- // --- 初始化/重置模式 ---
|
|
|
- kLineData.value = formattedData;
|
|
|
- if (formattedData.length > 0) {
|
|
|
- const lastBar = formattedData[formattedData.length - 1];
|
|
|
- marketInfo.value = { ...marketInfo.value, price: lastBar.close, fiatPrice: lastBar.close, high: lastBar.high, low: lastBar.low };
|
|
|
+ // 没数据了
|
|
|
+ if (endTime && klineRef.value) {
|
|
|
+ klineRef.value.applyHistoryData([], false);
|
|
|
}
|
|
|
}
|
|
|
- } else {
|
|
|
- // 没数据了
|
|
|
+ } catch (error) {
|
|
|
+ console.error("API Error", error);
|
|
|
if (endTime && klineRef.value) {
|
|
|
+ // 发生错误时也要告知 KLineChart 停止加载,否则 loading 动画会一直转
|
|
|
klineRef.value.applyHistoryData([], false);
|
|
|
}
|
|
|
+ } finally {
|
|
|
+ isLoadingMore.value = false;
|
|
|
}
|
|
|
- } catch (error) {
|
|
|
- console.error("API Error", error);
|
|
|
- if (endTime && klineRef.value) {
|
|
|
- // 发生错误时也要告知 KLineChart 停止加载,否则 loading 动画会一直转
|
|
|
- klineRef.value.applyHistoryData([], false);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleLoadMore = (timestamp) => {
|
|
|
+ if (isLoadingMore.value) return;
|
|
|
+ isLoadingMore.value = true;
|
|
|
+ getKlineData(timestamp);
|
|
|
+ };
|
|
|
+
|
|
|
+ // --- WebSocket ---
|
|
|
+ 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);
|
|
|
}
|
|
|
- } finally {
|
|
|
- isLoadingMore.value = false;
|
|
|
- }
|
|
|
-};
|
|
|
-
|
|
|
-const handleLoadMore = (timestamp) => {
|
|
|
- if (isLoadingMore.value) return;
|
|
|
- isLoadingMore.value = true;
|
|
|
- getKlineData(timestamp);
|
|
|
-};
|
|
|
-
|
|
|
-// --- WebSocket ---
|
|
|
-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) { kLineData.value = [newBar]; return; }
|
|
|
- const lastIndex = kLineData.value.length - 1;
|
|
|
- const lastBar = kLineData.value[lastIndex];
|
|
|
- // 简单的 WebSocket 去重
|
|
|
- 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 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");
|
|
|
+ };
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ kLineData.value = [newBar];
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const lastIndex = kLineData.value.length - 1;
|
|
|
+ const lastBar = kLineData.value[lastIndex];
|
|
|
+ // 简单的 WebSocket 去重
|
|
|
+ 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 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:5px 0 15px 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: #fff; 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; 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; width: 100%;
|
|
|
- position: relative; min-height: 100vh; background: #fff; }
|
|
|
-.market-price { display: flex; justify-content: space-between; margin-top: 4px;
|
|
|
- width: 100%; padding: 0 15px; box-sizing: border-box;
|
|
|
- .price-left { display: flex;line-height: 20px; flex-direction: column; width: 144px;
|
|
|
- .left-price { height: 18px; line-height: 18px; } .left-number { margin-top: 5px; }
|
|
|
- .left-appro { 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; } } } } }
|
|
|
-.k-line-main { height: 50vh; min-height: 350px; width: 100%; padding: 0 15px; }
|
|
|
-.notifi-classifi { display: flex; align-items: flex-end; margin-top: 140px;
|
|
|
- width: 100%; padding: 0 15px 0 15px; box-sizing: border-box; height: 24px;
|
|
|
- border-bottom: 1px solid #f5f5f5; .sys-notifi { margin-left: 47px; } }
|
|
|
-</style>
|
|
|
+ .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: 5px 0 15px 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: #fff;
|
|
|
+ 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;
|
|
|
+ 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;
|
|
|
+ width: 100%;
|
|
|
+ position: relative;
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #fff;
|
|
|
+ }
|
|
|
+ .market-price {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-top: 4px;
|
|
|
+ width: 100%;
|
|
|
+ padding: 0 15px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ .price-left {
|
|
|
+ display: flex;
|
|
|
+ line-height: 20px;
|
|
|
+ flex-direction: column;
|
|
|
+ width: 144px;
|
|
|
+ .left-price {
|
|
|
+ height: 18px;
|
|
|
+ line-height: 18px;
|
|
|
+ }
|
|
|
+ .left-number {
|
|
|
+ margin-top: 5px;
|
|
|
+ }
|
|
|
+ .left-appro {
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .k-line-main {
|
|
|
+ height: 50vh;
|
|
|
+ min-height: 350px;
|
|
|
+ width: 100%;
|
|
|
+ padding: 0 15px;
|
|
|
+ }
|
|
|
+ .notifi-classifi {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+ margin-top: 140px;
|
|
|
+ width: 100%;
|
|
|
+ padding: 0 15px 0 15px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ height: 24px;
|
|
|
+ .sys-notifi {
|
|
|
+ margin-left: 47px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|