|
@@ -1,5 +1,10 @@
|
|
|
<script setup>
|
|
<script setup>
|
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
|
|
|
|
+// 引入您的真实接口函数
|
|
|
|
|
+import { TradingPair } from '@/api/index.js';
|
|
|
|
|
+import { useRoute, useRouter } from "vue-router";
|
|
|
|
|
+
|
|
|
|
|
+const router = useRouter();
|
|
|
|
|
|
|
|
// --- 1. 状态管理 ---
|
|
// --- 1. 状态管理 ---
|
|
|
const tabs = [
|
|
const tabs = [
|
|
@@ -17,7 +22,14 @@ const finished = ref(false);
|
|
|
const scrollContainer = ref(null);
|
|
const scrollContainer = ref(null);
|
|
|
const PAGE_SIZE = 15; // 定义分页大小
|
|
const PAGE_SIZE = 15; // 定义分页大小
|
|
|
|
|
|
|
|
-// --- 2. 图表与图标工具函数 (保持不变) ---
|
|
|
|
|
|
|
+// --- 2. WebSocket 核心变量 ---
|
|
|
|
|
+const socket = ref(null);
|
|
|
|
|
+let reconnectTimer = null; // 重连定时器
|
|
|
|
|
+let heartbeatTimer = null; // 心跳定时器
|
|
|
|
|
+let isManualClose = false; // 标记是否为手动关闭(切换Tab时),避免触发重连
|
|
|
|
|
+let lastSymbols = []; // 记录最后一次订阅的币种,用于重连
|
|
|
|
|
+
|
|
|
|
|
+// --- 3. 图表与图标工具函数 ---
|
|
|
const generateChartPaths = (isUp) => {
|
|
const generateChartPaths = (isUp) => {
|
|
|
const width = 60; const height = 24; const pointCount = 15; const step = width / (pointCount - 1);
|
|
const width = 60; const height = 24; const pointCount = 15; const step = width / (pointCount - 1);
|
|
|
let points = []; let y = isUp ? (height * 0.8) : (height * 0.2);
|
|
let points = []; let y = isUp ? (height * 0.8) : (height * 0.2);
|
|
@@ -32,136 +44,206 @@ const generateChartPaths = (isUp) => {
|
|
|
return { linePath, fillPath };
|
|
return { linePath, fillPath };
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-const icons = {
|
|
|
|
|
|
|
+// 本地 SVG 图标映射 (作为兜底备用)
|
|
|
|
|
+const localIcons = {
|
|
|
BTC: '<path fill="#F7931A" d="M22.6 13.4c.4-2.6-1.6-4-4.3-4.9l.9-3.5-2.1-.5-.9 3.5c-.5-.1-1.1-.3-1.7-.4l.9-3.5-2.1-.5-.9 3.5c-.4-.1-1-.2-1.5-.3l-3-.7-.6 2.4s1.6.4 1.6.4c.9.2 1 .8 1 1.2l-1 4c.1 0 .1 0 .1.1l-1.4 5.6c0 .3-.3.8-1 .6 0 0-1.6-.4-1.6-.4l-1.1 2.6 2.8.7c.5.1 1 .3 1.5.4l-.9 3.6 2.1.5.9-3.6c.6.2 1.1.3 1.7.5l-.9 3.6 2.1.5.9-3.5c3.7.7 6.4.4 7.6-2.9.9-2.6-.1-4.1-1.9-5.1 1.4-.3 2.4-1.2 2.7-3z"/>',
|
|
BTC: '<path fill="#F7931A" d="M22.6 13.4c.4-2.6-1.6-4-4.3-4.9l.9-3.5-2.1-.5-.9 3.5c-.5-.1-1.1-.3-1.7-.4l.9-3.5-2.1-.5-.9 3.5c-.4-.1-1-.2-1.5-.3l-3-.7-.6 2.4s1.6.4 1.6.4c.9.2 1 .8 1 1.2l-1 4c.1 0 .1 0 .1.1l-1.4 5.6c0 .3-.3.8-1 .6 0 0-1.6-.4-1.6-.4l-1.1 2.6 2.8.7c.5.1 1 .3 1.5.4l-.9 3.6 2.1.5.9-3.6c.6.2 1.1.3 1.7.5l-.9 3.6 2.1.5.9-3.5c3.7.7 6.4.4 7.6-2.9.9-2.6-.1-4.1-1.9-5.1 1.4-.3 2.4-1.2 2.7-3z"/>',
|
|
|
ETH: '<path fill="#627EEA" d="M11.9 20.3L6.1 16.9l5.8-2.6 5.8 2.6-5.8 3.4zm0-9.6l5.8 2.6-5.8 8.1-5.8-8.1 5.8-2.6zM12 2l5.8 9.6L12 14 6.2 11.6 12 2z"/>',
|
|
ETH: '<path fill="#627EEA" d="M11.9 20.3L6.1 16.9l5.8-2.6 5.8 2.6-5.8 3.4zm0-9.6l5.8 2.6-5.8 8.1-5.8-8.1 5.8-2.6zM12 2l5.8 9.6L12 14 6.2 11.6 12 2z"/>',
|
|
|
BNB: '<path fill="#F3BA2F" d="M4.6 12l2.3-2.3L9.2 12l-2.3 2.3L4.6 12zM12 4.6l2.3 2.3-2.3 2.3-2.3-2.3L12 4.6zm7.4 7.4l-2.3 2.3 2.3 2.3 2.3-2.3-2.3-2.3zm-7.4 7.4l-2.3-2.3 2.3-2.3 2.3 2.3-2.3 2.3zm4.6-7.4l2.3-2.3-2.3-2.3-2.3 2.3 2.3 2.3zM12 14.8l-2.3-2.3 2.3-2.3 2.3 2.3L12 14.8zM9.7 7.4L12 5.1l2.3 2.3L12 9.7 9.7 7.4z"/>',
|
|
BNB: '<path fill="#F3BA2F" d="M4.6 12l2.3-2.3L9.2 12l-2.3 2.3L4.6 12zM12 4.6l2.3 2.3-2.3 2.3-2.3-2.3L12 4.6zm7.4 7.4l-2.3 2.3 2.3 2.3 2.3-2.3-2.3-2.3zm-7.4 7.4l-2.3-2.3 2.3-2.3 2.3 2.3-2.3 2.3zm4.6-7.4l2.3-2.3-2.3-2.3-2.3 2.3 2.3 2.3zM12 14.8l-2.3-2.3 2.3-2.3 2.3 2.3L12 14.8zM9.7 7.4L12 5.1l2.3 2.3L12 9.7 9.7 7.4z"/>',
|
|
|
- USDT: '<path fill="#26A17B" d="M14.5 10.4c.2-.2.3-.3.3-.3s-.1 0-.3 0c-.5.1-1.2.1-1.9.1-.1-2.9 0-2.9 0-2.9h3.1V5.7h-3.1V3H11v2.7H8V7.3h3v2.9c0 .1 0 .2-.1.2-2.8 0-5.1.8-5.1 1.7 0 1 2.3 1.7 5.2 1.7s5.2-.8 5.2-1.7c0-.7-1.3-1.2-3.4-1.5z"/>'
|
|
|
|
|
|
|
+ USDT: '<path fill="#26A17B" d="M14.5 10.4c.2-.2.3-.3.3-.3s-.1 0-.3 0c-.5.1-1.2.1-1.9.1-.1-2.9 0-2.9 0-2.9h3.1V5.7h-3.1V3H11v2.7H8V7.3h3v2.9c0 .1 0 .2-.1.2-2.8 0-5.1.8-5.1 1.7 0 1 2.3 1.7 5.2 1.7s5.2-.8 5.2-1.7c0-.7-1.3-1.2-3.4-1.5z"/>',
|
|
|
|
|
+ XRP: '<path fill="#23292F" d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" /><path fill="#FFF" d="M9.82 12.01L4.65 6.84a.856.856 0 011.21-1.21l5.17 5.17a1.14 1.14 0 010 1.62L5.86 17.59a.856.856 0 01-1.21-1.21l5.17-4.37zM14.18 12.01l5.17 5.17a.856.856 0 01-1.21 1.21l-5.17-5.17a1.14 1.14 0 010-1.62l5.17-5.17a.856.856 0 011.21 1.21l-5.17 4.37z"/>',
|
|
|
|
|
+ SOL: '<path fill="#00FFA3" d="M3.5 16.5l2.1-3.6 14.9 0L18.4 16.5 3.5 16.5zM5.6 7.5L3.5 11.1 18.4 11.1 20.5 7.5 5.6 7.5zM20.5 20.1l-2.1 3.6L3.5 23.7 5.6 20.1 20.5 20.1z"/>',
|
|
|
|
|
+ DOGE: '<path fill="#C2A633" d="M12,2C6.48,2,2,6.48,2,12s4.48,10,10,10s10-4.48,10-10S17.52,2,12,2z M12,18c-3.31,0-6-2.69-6-6s2.69-6,6-6s6,2.69,6,6S15.31,18,12,18z"/>'
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// --- 3. 模拟 WebSocket 服务 ---
|
|
|
|
|
-const mockSocket = {
|
|
|
|
|
- activeSubs: new Set(),
|
|
|
|
|
- interval: null,
|
|
|
|
|
- subscribe(ids, callback) {
|
|
|
|
|
- ids.forEach(id => this.activeSubs.add(id));
|
|
|
|
|
- if (!this.interval) {
|
|
|
|
|
- this.interval = setInterval(() => {
|
|
|
|
|
- const subArray = Array.from(this.activeSubs);
|
|
|
|
|
- if (subArray.length === 0) return;
|
|
|
|
|
- const updates = [];
|
|
|
|
|
- const updateCount = Math.floor(Math.random() * 5) + 1;
|
|
|
|
|
- for(let i=0; i<updateCount; i++) {
|
|
|
|
|
- const randomId = subArray[Math.floor(Math.random() * subArray.length)];
|
|
|
|
|
- const isUp = Math.random() > 0.5;
|
|
|
|
|
- updates.push({
|
|
|
|
|
- id: randomId,
|
|
|
|
|
- price: 40000 + Math.random() * 1000,
|
|
|
|
|
- change: (isUp ? 1 : -1) * (Math.random() * 5).toFixed(2),
|
|
|
|
|
- isUp: isUp
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- callback(updates);
|
|
|
|
|
- }, 500);
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- unsubscribeAll() {
|
|
|
|
|
- this.activeSubs.clear();
|
|
|
|
|
- if (this.interval) {
|
|
|
|
|
- clearInterval(this.interval);
|
|
|
|
|
- this.interval = null;
|
|
|
|
|
|
|
+// --- 4. 生产级 WebSocket 管理 ---
|
|
|
|
|
+
|
|
|
|
|
+// 启动心跳:防止连接因长时间无数据而被断开
|
|
|
|
|
+const startHeartbeat = () => {
|
|
|
|
|
+ clearInterval(heartbeatTimer);
|
|
|
|
|
+ heartbeatTimer = setInterval(() => {
|
|
|
|
|
+ if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
|
|
|
|
+ // 发送 ping 消息,具体内容看后端要求,通常是字符串 'ping' 或 JSON '{ "op": "ping" }'
|
|
|
|
|
+ socket.value.send("ping");
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
+ }, 15000); // 建议 15-30 秒一次
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// --- 4. 模拟 REST API ---
|
|
|
|
|
-const mockFetchData = (tab, pageNum) => {
|
|
|
|
|
- return new Promise((resolve) => {
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- // 为了测试效果,改为只返回 10 条数据(小于 PAGE_SIZE 15)
|
|
|
|
|
- // 这样第一页加载完就会立刻显示“没有更多了”
|
|
|
|
|
- const count = 10;
|
|
|
|
|
-
|
|
|
|
|
- // 如果不是第一页,就直接返回空,确保结束
|
|
|
|
|
- if (pageNum > 1) { resolve([]); return; }
|
|
|
|
|
-
|
|
|
|
|
- const newItems = Array.from({ length: count }).map((_, i) => {
|
|
|
|
|
- const id = `${tab}-${(pageNum - 1) * PAGE_SIZE + i + 1}`;
|
|
|
|
|
- const isUp = Math.random() > 0.5;
|
|
|
|
|
- const paths = generateChartPaths(isUp);
|
|
|
|
|
-
|
|
|
|
|
- let symbolPrefix = 'ABC';
|
|
|
|
|
- if (tab === 'fav') symbolPrefix = 'FAV';
|
|
|
|
|
- if (tab === 'spot') symbolPrefix = 'BTC';
|
|
|
|
|
- if (tab === 'futures') symbolPrefix = 'ETH';
|
|
|
|
|
-
|
|
|
|
|
- let realSymbol = symbolPrefix + ((pageNum - 1) * PAGE_SIZE + i + 1);
|
|
|
|
|
- if (i % 5 === 0) realSymbol = 'BTC';
|
|
|
|
|
- if (i % 5 === 1) realSymbol = 'ETH';
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- id: id,
|
|
|
|
|
- symbol: realSymbol,
|
|
|
|
|
- name: `${realSymbol} Network`,
|
|
|
|
|
- price: 40000 + Math.random() * 5000,
|
|
|
|
|
- cny: 280000 + Math.random() * 30000,
|
|
|
|
|
- change: (isUp ? 1 : -1) * (Math.random() * 10).toFixed(2),
|
|
|
|
|
- svgIcon: icons[realSymbol],
|
|
|
|
|
- chartLine: paths.linePath,
|
|
|
|
|
- chartFill: paths.fillPath,
|
|
|
|
|
- chartColor: isUp ? '#2EBD85' : '#F6465D',
|
|
|
|
|
- btnClass: isUp ? 'btn-green' : 'btn-red'
|
|
|
|
|
- };
|
|
|
|
|
- });
|
|
|
|
|
- resolve(newItems);
|
|
|
|
|
- }, 500);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+const stopHeartbeat = () => {
|
|
|
|
|
+ clearInterval(heartbeatTimer);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// --- 5. 核心逻辑:数据加载与订阅 ---
|
|
|
|
|
-
|
|
|
|
|
-const handleSocketUpdate = (updates) => {
|
|
|
|
|
- updates.forEach(update => {
|
|
|
|
|
- const item = listData.value.find(i => i.id === update.id);
|
|
|
|
|
- if (item) {
|
|
|
|
|
- item.price = update.price;
|
|
|
|
|
- item.change = update.change;
|
|
|
|
|
- item.cny = update.price * 7.2;
|
|
|
|
|
- const isUp = update.change >= 0;
|
|
|
|
|
- item.btnClass = isUp ? 'btn-green' : 'btn-red';
|
|
|
|
|
- item.chartColor = isUp ? '#2EBD85' : '#F6465D';
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
|
|
+const connectWebSocket = (symbols) => {
|
|
|
|
|
+ // 如果是重连调用(不传参数),则使用上次的币种
|
|
|
|
|
+ if (!symbols && lastSymbols.length > 0) {
|
|
|
|
|
+ symbols = lastSymbols;
|
|
|
|
|
+ } else if (symbols) {
|
|
|
|
|
+ lastSymbols = symbols;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return; // 无币种可订阅
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 关闭旧连接
|
|
|
|
|
+ isManualClose = true; // 标记为手动关闭,防止立刻触发 onclose 重连
|
|
|
|
|
+ if (socket.value) {
|
|
|
|
|
+ socket.value.close();
|
|
|
|
|
+ }
|
|
|
|
|
+ clearTimeout(reconnectTimer);
|
|
|
|
|
+ stopHeartbeat();
|
|
|
|
|
+
|
|
|
|
|
+ // 构造参数
|
|
|
|
|
+ const symbolStr = symbols.map(s => s.toLowerCase()).join('/');
|
|
|
|
|
+ if (!symbolStr) return;
|
|
|
|
|
+
|
|
|
|
|
+ const wsUrl = `ws://localhost:8080/ws/kline/?symbol=${symbolStr}`;
|
|
|
|
|
+ console.log('Connecting WS:', wsUrl);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ isManualClose = false; // 重置标记,准备开始新连接
|
|
|
|
|
+ socket.value = new WebSocket(wsUrl);
|
|
|
|
|
+
|
|
|
|
|
+ socket.value.onopen = () => {
|
|
|
|
|
+ console.log('WS Connected');
|
|
|
|
|
+ startHeartbeat(); // 连接成功,开启心跳
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ socket.value.onmessage = (event) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const msg = JSON.parse(event.data);
|
|
|
|
|
+ // 如果收到 pong 消息,可以忽略
|
|
|
|
|
+ if (msg === 'pong' || msg.op === 'pong') return;
|
|
|
|
|
+
|
|
|
|
|
+ const data = msg.data || msg;
|
|
|
|
|
+ const symbol = data.s || data.symbol;
|
|
|
|
|
+ if (!symbol) return;
|
|
|
|
|
+
|
|
|
|
|
+ const item = listData.value.find(i =>
|
|
|
|
|
+ i.name && i.name.toUpperCase() === symbol.toUpperCase()
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (item) {
|
|
|
|
|
+ // 1. 实时更新价格
|
|
|
|
|
+ let newPrice = data.c || data.p || (data.k ? data.k.c : null);
|
|
|
|
|
+ if (newPrice) {
|
|
|
|
|
+ item.price = newPrice;
|
|
|
|
|
+ item.cny = (parseFloat(newPrice) * 7.25).toFixed(2);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 实时更新涨跌幅
|
|
|
|
|
+ let newChange = data.P || data.trend;
|
|
|
|
|
+ if (newChange) {
|
|
|
|
|
+ item.change = parseFloat(newChange).toFixed(2);
|
|
|
|
|
+ const isUp = parseFloat(newChange) >= 0;
|
|
|
|
|
+ item.btnClass = isUp ? 'btn-green' : 'btn-red';
|
|
|
|
|
+ item.chartColor = isUp ? '#2EBD85' : '#F6465D';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // console.error("WS Message Error:", e);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ socket.value.onclose = (e) => {
|
|
|
|
|
+ stopHeartbeat();
|
|
|
|
|
+ // 如果不是手动切换Tab导致的关闭,则尝试重连
|
|
|
|
|
+ if (!isManualClose) {
|
|
|
|
|
+ console.log('WS Disconnected unexpectedly. Reconnecting in 3s...');
|
|
|
|
|
+ reconnectTimer = setTimeout(() => {
|
|
|
|
|
+ connectWebSocket(); // 尝试重连
|
|
|
|
|
+ }, 3000);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ socket.value.onerror = (err) => {
|
|
|
|
|
+ console.warn("WS Connection Error:", err);
|
|
|
|
|
+ // error 后通常会自动触发 close,逻辑交给 onclose 处理
|
|
|
|
|
+ };
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error("WS Create Error:", e);
|
|
|
|
|
+ // 创建失败也尝试重连
|
|
|
|
|
+ reconnectTimer = setTimeout(() => {
|
|
|
|
|
+ connectWebSocket();
|
|
|
|
|
+ }, 5000);
|
|
|
|
|
+ }
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+// --- 5. 真实接口请求封装 ---
|
|
|
|
|
+const fetchData = async (tab, pageNum) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (tab !== 'spot') return [];
|
|
|
|
|
+ const res = await TradingPair({
|
|
|
|
|
+ type: tab,
|
|
|
|
|
+ pageNum: pageNum,
|
|
|
|
|
+ pageSize: PAGE_SIZE
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!res || !res.list) return [];
|
|
|
|
|
+
|
|
|
|
|
+ return res.list.map(item => {
|
|
|
|
|
+ const trendVal = parseFloat(item.trend || 0);
|
|
|
|
|
+ const isUp = trendVal >= 0;
|
|
|
|
|
+ const paths = generateChartPaths(isUp);
|
|
|
|
|
+ const iconKey = item.base_coin;
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: item.id,
|
|
|
|
|
+ name: item.symbol,
|
|
|
|
|
+ symbol: item.name,
|
|
|
|
|
+ price: item.current_price,
|
|
|
|
|
+ cny: (parseFloat(item.current_price) * 7.25).toFixed(2),
|
|
|
|
|
+ change: trendVal.toFixed(2),
|
|
|
|
|
+ iconUrl: item.logo,
|
|
|
|
|
+ svgIcon: localIcons[iconKey] || null,
|
|
|
|
|
+ base_coin_char: item.base_coin ? item.base_coin[0] : '?',
|
|
|
|
|
+ chartLine: paths.linePath,
|
|
|
|
|
+ chartFill: paths.fillPath,
|
|
|
|
|
+ chartColor: isUp ? '#2EBD85' : '#F6465D',
|
|
|
|
|
+ btnClass: isUp ? 'btn-green' : 'btn-red'
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error("加载数据失败:", error);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// --- 6. 核心逻辑:数据加载 ---
|
|
|
const onLoad = async () => {
|
|
const onLoad = async () => {
|
|
|
if (loading.value || finished.value) return;
|
|
if (loading.value || finished.value) return;
|
|
|
|
|
|
|
|
loading.value = true;
|
|
loading.value = true;
|
|
|
- const data = await mockFetchData(currentTab.value, page.value);
|
|
|
|
|
|
|
|
|
|
- // 核心优化逻辑:
|
|
|
|
|
|
|
+ const data = await fetchData(currentTab.value, page.value);
|
|
|
|
|
+
|
|
|
|
|
+ // console.log(`Tab: ${currentTab.value}, Page: ${page.value}, Loaded: ${data.length}`);
|
|
|
|
|
+
|
|
|
if (data.length === 0) {
|
|
if (data.length === 0) {
|
|
|
finished.value = true;
|
|
finished.value = true;
|
|
|
} else {
|
|
} else {
|
|
|
listData.value.push(...data);
|
|
listData.value.push(...data);
|
|
|
page.value++;
|
|
page.value++;
|
|
|
|
|
|
|
|
- // 如果返回的数据条数小于分页大小,说明已经是最后一页了
|
|
|
|
|
- // 直接标记完成,这样不需要再发一次空请求
|
|
|
|
|
if (data.length < PAGE_SIZE) {
|
|
if (data.length < PAGE_SIZE) {
|
|
|
finished.value = true;
|
|
finished.value = true;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- const newIds = data.map(item => item.id);
|
|
|
|
|
- mockSocket.subscribe(newIds, handleSocketUpdate);
|
|
|
|
|
|
|
+ console.log(listData.value,'llll');
|
|
|
|
|
+ const allSymbols = listData.value.map(item => item.name);
|
|
|
|
|
+ if (allSymbols.length > 0) {
|
|
|
|
|
+ connectWebSocket(allSymbols);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
loading.value = false;
|
|
loading.value = false;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// 监听 Tab 切换
|
|
// 监听 Tab 切换
|
|
|
watch(currentTab, () => {
|
|
watch(currentTab, () => {
|
|
|
- mockSocket.unsubscribeAll();
|
|
|
|
|
|
|
+ isManualClose = true; // 切换前标记手动关闭
|
|
|
|
|
+ if (socket.value) {
|
|
|
|
|
+ socket.value.close();
|
|
|
|
|
+ socket.value = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 清理重连定时器,避免在切换Tab期间触发上一个Tab的重连
|
|
|
|
|
+ clearTimeout(reconnectTimer);
|
|
|
|
|
+
|
|
|
listData.value = [];
|
|
listData.value = [];
|
|
|
page.value = 1;
|
|
page.value = 1;
|
|
|
finished.value = false;
|
|
finished.value = false;
|
|
@@ -172,16 +254,46 @@ watch(currentTab, () => {
|
|
|
|
|
|
|
|
const handleScroll = (e) => {
|
|
const handleScroll = (e) => {
|
|
|
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
|
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
|
|
- if (scrollTop + clientHeight >= scrollHeight - 50) {
|
|
|
|
|
|
|
+ if (scrollTop + clientHeight >= scrollHeight - 100) {
|
|
|
onLoad();
|
|
onLoad();
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-onMounted(() => onLoad());
|
|
|
|
|
-onUnmounted(() => mockSocket.unsubscribeAll());
|
|
|
|
|
|
|
+// 处理页面可见性变化(切后台再回来)
|
|
|
|
|
+const handleVisibilityChange = () => {
|
|
|
|
|
+ if (document.visibilityState === 'visible') {
|
|
|
|
|
+ // 如果回来发现连接断了,立即重连
|
|
|
|
|
+ if (!socket.value || socket.value.readyState === WebSocket.CLOSED) {
|
|
|
|
|
+ console.log('Page visible, checking WS connection...');
|
|
|
|
|
+ connectWebSocket();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
-const formatPrice = (val) => val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
|
-const formatCNY = (val) => val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ onLoad();
|
|
|
|
|
+ document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+onUnmounted(() => {
|
|
|
|
|
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
|
+ isManualClose = true;
|
|
|
|
|
+ if (socket.value) socket.value.close();
|
|
|
|
|
+ clearTimeout(reconnectTimer);
|
|
|
|
|
+ stopHeartbeat();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 格式化工具函数
|
|
|
|
|
+const formatPrice = (val) => {
|
|
|
|
|
+ if(!val) return '0.00';
|
|
|
|
|
+ const num = parseFloat(val);
|
|
|
|
|
+ return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8 });
|
|
|
|
|
+};
|
|
|
|
|
+const formatCNY = (val) => {
|
|
|
|
|
+ if(!val) return '0.00';
|
|
|
|
|
+ const num = parseFloat(val);
|
|
|
|
|
+ return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
|
+};
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
@@ -218,10 +330,29 @@ const formatCNY = (val) => val.toLocaleString('en-US', { minimumFractionDigits:
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <!-- 表头增加排序图标 -->
|
|
|
<div class="table-header">
|
|
<div class="table-header">
|
|
|
- <div class="col col-left">交易对</div>
|
|
|
|
|
- <div class="col col-center">最新价</div>
|
|
|
|
|
- <div class="col col-right">今日涨跌幅</div>
|
|
|
|
|
|
|
+ <div class="col col-left">
|
|
|
|
|
+ 交易对
|
|
|
|
|
+ <div class="sort-box">
|
|
|
|
|
+ <div class="sort-up"></div>
|
|
|
|
|
+ <div class="sort-down"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="col col-center">
|
|
|
|
|
+ 最新价
|
|
|
|
|
+ <div class="sort-box">
|
|
|
|
|
+ <div class="sort-up"></div>
|
|
|
|
|
+ <div class="sort-down"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="col col-right">
|
|
|
|
|
+ 今日涨跌幅
|
|
|
|
|
+ <div class="sort-box">
|
|
|
|
|
+ <div class="sort-up"></div>
|
|
|
|
|
+ <div class="sort-down"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -234,11 +365,13 @@ const formatCNY = (val) => val.toLocaleString('en-US', { minimumFractionDigits:
|
|
|
v-for="coin in listData"
|
|
v-for="coin in listData"
|
|
|
:key="coin.id"
|
|
:key="coin.id"
|
|
|
class="list-item"
|
|
class="list-item"
|
|
|
|
|
+ @click="router.push({ path: '/marketDetails', query: { id: coin.id, type: coin.name.toLowerCase()} })"
|
|
|
>
|
|
>
|
|
|
<div class="col col-left coin-info">
|
|
<div class="col col-left coin-info">
|
|
|
<div class="coin-icon-wrapper">
|
|
<div class="coin-icon-wrapper">
|
|
|
- <svg v-if="coin.svgIcon" viewBox="0 0 32 32" class="real-icon" v-html="coin.svgIcon"></svg>
|
|
|
|
|
- <div v-else class="placeholder-icon">{{ coin.symbol[0] }}</div>
|
|
|
|
|
|
|
+ <img v-if="coin.iconUrl" :src="coin.iconUrl" class="real-icon-img" alt="icon" />
|
|
|
|
|
+ <svg v-else-if="coin.svgIcon" viewBox="0 0 32 32" class="real-icon" v-html="coin.svgIcon"></svg>
|
|
|
|
|
+ <div v-else class="placeholder-icon">{{ coin.base_coin_char }}</div>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="text-group">
|
|
<div class="text-group">
|
|
|
<div class="name-row">{{ coin.name }}</div>
|
|
<div class="name-row">{{ coin.name }}</div>
|
|
@@ -266,16 +399,18 @@ const formatCNY = (val) => val.toLocaleString('en-US', { minimumFractionDigits:
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <!-- 底部状态区:使用 v-show 或 v-if 确保渲染 -->
|
|
|
|
|
|
|
+ <!-- 底部状态区 -->
|
|
|
<div class="loading-state">
|
|
<div class="loading-state">
|
|
|
<div v-if="loading" class="spinner-container">
|
|
<div v-if="loading" class="spinner-container">
|
|
|
<div class="spinner"></div>
|
|
<div class="spinner"></div>
|
|
|
<span class="loading-text">加载中...</span>
|
|
<span class="loading-text">加载中...</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <!-- 这里确保 finished 为 true 时一定会显示 -->
|
|
|
|
|
- <div v-else-if="finished" class="no-more-text">
|
|
|
|
|
|
|
+ <div v-else-if="finished && listData.length > 0" class="no-more-text">
|
|
|
- 没有更多了 -
|
|
- 没有更多了 -
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div v-else-if="finished && listData.length === 0" class="empty-state">
|
|
|
|
|
+ 暂无数据
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -353,13 +488,36 @@ const formatCNY = (val) => val.toLocaleString('en-US', { minimumFractionDigits:
|
|
|
.col { display: flex; }
|
|
.col { display: flex; }
|
|
|
.col-left { width: 38%; justify-content: flex-start; align-items: center; }
|
|
.col-left { width: 38%; justify-content: flex-start; align-items: center; }
|
|
|
.col-center { width: 25%; justify-content: center; align-items: center; margin-left: 10px; }
|
|
.col-center { width: 25%; justify-content: center; align-items: center; margin-left: 10px; }
|
|
|
|
|
+/* 修正右侧列样式,支持排序图标 */
|
|
|
.col-right { width: 37%; justify-content: flex-end; align-items: center; }
|
|
.col-right { width: 37%; justify-content: flex-end; align-items: center; }
|
|
|
|
|
|
|
|
|
|
+/* 排序小三角 */
|
|
|
|
|
+.sort-box {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ margin-left: 4px;
|
|
|
|
|
+ gap: 2px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+}
|
|
|
|
|
+.sort-up {
|
|
|
|
|
+ width: 0;
|
|
|
|
|
+ height: 0;
|
|
|
|
|
+ border-left: 3px solid transparent;
|
|
|
|
|
+ border-right: 3px solid transparent;
|
|
|
|
|
+ border-bottom: 4px solid #B7BDC6;
|
|
|
|
|
+}
|
|
|
|
|
+.sort-down {
|
|
|
|
|
+ width: 0;
|
|
|
|
|
+ height: 0;
|
|
|
|
|
+ border-left: 3px solid transparent;
|
|
|
|
|
+ border-right: 3px solid transparent;
|
|
|
|
|
+ border-top: 4px solid #B7BDC6;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.list-container {
|
|
.list-container {
|
|
|
flex: 1;
|
|
flex: 1;
|
|
|
overflow-y: auto;
|
|
overflow-y: auto;
|
|
|
- /* 增加底部 Padding,确保“没有更多了”不被遮挡 */
|
|
|
|
|
- padding: 0 15px 60px;
|
|
|
|
|
|
|
+ padding: 0 15px 80px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.list-item {
|
|
.list-item {
|
|
@@ -371,8 +529,10 @@ const formatCNY = (val) => val.toLocaleString('en-US', { minimumFractionDigits:
|
|
|
|
|
|
|
|
.coin-icon-wrapper {
|
|
.coin-icon-wrapper {
|
|
|
width: 28px; height: 28px; margin-right: 8px; flex-shrink: 0;
|
|
width: 28px; height: 28px; margin-right: 8px; flex-shrink: 0;
|
|
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
}
|
|
}
|
|
|
.real-icon { width: 100%; height: 100%; }
|
|
.real-icon { width: 100%; height: 100%; }
|
|
|
|
|
+.real-icon-img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
|
|
|
.placeholder-icon {
|
|
.placeholder-icon {
|
|
|
width: 100%; height: 100%; background: #F0F3F5; border-radius: 50%;
|
|
width: 100%; height: 100%; background: #F0F3F5; border-radius: 50%;
|
|
|
color: #707A8A; display: flex; align-items: center; justify-content: center;
|
|
color: #707A8A; display: flex; align-items: center; justify-content: center;
|
|
@@ -407,7 +567,7 @@ const formatCNY = (val) => val.toLocaleString('en-US', { minimumFractionDigits:
|
|
|
display: flex;
|
|
display: flex;
|
|
|
justify-content: center;
|
|
justify-content: center;
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
- min-height: 80px; /* 增加高度确保可见 */
|
|
|
|
|
|
|
+ min-height: 80px;
|
|
|
width: 100%;
|
|
width: 100%;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -431,11 +591,18 @@ const formatCNY = (val) => val.toLocaleString('en-US', { minimumFractionDigits:
|
|
|
color: #999;
|
|
color: #999;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/* 优化后的“没有更多了”样式:深灰色文字 */
|
|
|
|
|
.no-more-text {
|
|
.no-more-text {
|
|
|
- color: #999;
|
|
|
|
|
- font-size: 12px;
|
|
|
|
|
|
|
+ color: #555;
|
|
|
|
|
+ font-size: 13px;
|
|
|
padding: 8px 16px;
|
|
padding: 8px 16px;
|
|
|
|
|
+ background-color: #f0f0f0;
|
|
|
|
|
+ border-radius: 20px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.empty-state {
|
|
|
|
|
+ color: #999;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ padding-top: 40px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@keyframes spin {
|
|
@keyframes spin {
|