HotCoin.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. <template>
  2. <div class="hot-coin">
  3. <div class="coin-title pf600 fs18 fc121212">热门币种</div>
  4. <div class="coin-head">
  5. <div class="name">
  6. <div class="name-text pf400 fs12 fc666666">交易对</div>
  7. <div class="list-sort">
  8. <div class="sort-up">
  9. <img src="../../../assets/icon/index/Triangle.svg" alt="" />
  10. </div>
  11. <div class="sort-bottom">
  12. <img src="../../../assets/icon/index/Triangle 2.svg" alt="" />
  13. </div>
  14. </div>
  15. </div>
  16. <div class="name price">
  17. <div class="name-text pf400 fs12 fc666666">最新价</div>
  18. <div class="list-sort">
  19. <div class="sort-up">
  20. <img src="../../../assets/icon/index/Triangle.svg" alt="" />
  21. </div>
  22. <div class="sort-bottom">
  23. <img src="../../../assets/icon/index/Triangle 2.svg" alt="" />
  24. </div>
  25. </div>
  26. </div>
  27. <div class="name today">
  28. <div class="name-text pf400 fs12 fc666666">今日涨跌幅</div>
  29. <div class="list-sort">
  30. <div class="sort-up">
  31. <img src="../../../assets/icon/index/Triangle.svg" alt="" />
  32. </div>
  33. <div class="sort-bottom">
  34. <img src="../../../assets/icon/index/Triangle 2.svg" alt="" />
  35. </div>
  36. </div>
  37. </div>
  38. </div>
  39. <div class="coin-body" >
  40. <div v-show="item.change_rate" class="body-item" v-for="(item, index) in coinList" :key="index">
  41. <div class="item-left" @click="router.push({ path: '/marketDetails', query: { id: item.id,type: item.symbol.toLowerCase()} })">
  42. <div class="coin-img" >
  43. <img :src="item.logo" alt="" />
  44. </div>
  45. <div class="coin-name">
  46. <div class="upper-name pf500 fs14 fc2C3131">{{ formatSymbol(item.symbol) }}</div>
  47. <div class="letter-name pf400 fs10 fcA9A9A9">{{ item.name }}</div>
  48. </div>
  49. <div class="coin-echars"></div>
  50. <div class="coin-price">
  51. <div class="upper-price pf500 fs14 fc2C3131">{{ formatPrice(item.price) }}</div>
  52. <div class="letter-price pf400 fs10 fcA9A9A9">≈ ${{ formatPrice(item.price) }}</div>
  53. </div>
  54. </div>
  55. <div class="item-right pf500 fs12 fcFFFFFF" :class="getChangeColor(item.change_rate)">{{ formatChange(item.change_rate) }}</div>
  56. </div>
  57. </div>
  58. </div>
  59. </template>
  60. <script setup>
  61. import { GetCoins } from '@/api/index'
  62. import { ref, onMounted, onUnmounted } from 'vue'
  63. import { useRoute, useRouter } from "vue-router";
  64. const router = useRouter();
  65. const coinList = ref([])
  66. let socket = null
  67. let heartbeatTimer = null
  68. let reconnectTimer = null
  69. let isUnmounted = false
  70. // --- 辅助函数 ---
  71. const formatSymbol = (symbol) => symbol ? symbol.replace('USDT', '') : ''
  72. const formatPrice = (price) => price ? parseFloat(price).toFixed(2) : '0.00'
  73. // 格式化涨跌幅:+9.01%
  74. const formatChange = (val) => {
  75. if (!val) return '+0.00%'
  76. const num = parseFloat(val)
  77. // 正数加 + 号,负数自带 - 号
  78. return (num > 0 ? '+' : '') + num.toFixed(2) + '%'
  79. }
  80. // 获取颜色:涨绿跌红
  81. const getChangeColor = (val) => {
  82. if (!val) return 'bg-gray' // 没有数据时显示灰色
  83. return parseFloat(val) >= 0 ? 'bg-green' : 'bg-red'
  84. }
  85. // --- 1. WebSocket 核心逻辑 ---
  86. const initWebSocket = () => {
  87. // 🔒【防死循环】清理旧连接
  88. if (socket) {
  89. socket.onclose = null
  90. socket.close()
  91. socket = null
  92. }
  93. if (coinList.value.length === 0) return
  94. // 🔒【参数生成】
  95. // 列表里是 BTCUSDT (大写) -> 转成 btcusdt (小写) -> 用 / 拼接
  96. // 结果: "btcusdt/ethusdt/bnbusdt..."
  97. const symbolsParam = coinList.value
  98. .map(item => item.symbol.toLowerCase())
  99. .join('/')
  100. const query = `?symbol=${symbolsParam}`
  101. // 2. 确定地址
  102. // 暂时直连真实 IP,排除本地代理干扰
  103. // const host = '63.141.230.43:57676'
  104. // const host = 'http://localhost:8080'
  105. // 等调试通了,以后上线前可以改回这样:
  106. const host = process.env.NODE_ENV === 'production'
  107. ? 'backend.66linknow.com'
  108. : 'localhost:8080' // 开发环境走代理
  109. const wsUrl = `ws://${host}/ws/kline/${query}`
  110. // console.log('🚀 开始连接:', wsUrl)
  111. try {
  112. socket = new WebSocket(wsUrl)
  113. } catch (err) {
  114. // console.error('WS 初始化失败:', err)
  115. reconnect()
  116. return
  117. }
  118. socket.onopen = () => {
  119. // console.log('✅ 连接成功')
  120. startHeartbeat()
  121. if (reconnectTimer) clearTimeout(reconnectTimer)
  122. }
  123. socket.onmessage = (event) => {
  124. if (event.data === 'pong' || event.data === 'ping') return
  125. try {
  126. const msg = JSON.parse(event.data)
  127. // 兼容两种数据结构 (有时候后端会包一层 data)
  128. if (msg.data) {
  129. updateCoinData(msg.data)
  130. } else {
  131. updateCoinData(msg)
  132. }
  133. } catch (e) {}
  134. }
  135. socket.onerror = (err) => {
  136. console.error('❌ WS 报错')
  137. }
  138. socket.onclose = (e) => {
  139. // console.log(`⚠️ 断开 (Code: ${e.code})`)
  140. // console.log('关闭原因:', e)
  141. // console.log('是否正常关闭:', e.wasClean)
  142. if (e.code === 1000) return
  143. socket = null
  144. reconnect()
  145. }
  146. }
  147. // --- 2. 更新数据 (核心适配) ---
  148. const updateCoinData = (ticker) => {
  149. // ticker 是 WS 推送的数据:
  150. // { s: "ASTERUSDT", c: "1.016", P: "9.013", ... }
  151. if (!ticker || !ticker.s) return
  152. // 1. 找到列表里对应的币
  153. // 列表里是 "BTCUSDT",WS 推送里 s 也是 "BTCUSDT"
  154. // 统一转大写对比,确保匹配
  155. const targetCoin = coinList.value.find(item =>
  156. item.symbol.toUpperCase() === ticker.s.toUpperCase()
  157. )
  158. if (targetCoin) {
  159. // 2. 更新价格 (c = current price)
  160. if (ticker.c) targetCoin.price = ticker.c
  161. // 3. 更新涨跌幅 (P = percentage change)
  162. // 把 WS 里的 P 字段赋值给列表项的 change_rate
  163. if (ticker.P) targetCoin.change_rate = ticker.P
  164. }
  165. }
  166. // --- 3. 自动重连 ---
  167. const reconnect = () => {
  168. if (isUnmounted) return
  169. if (reconnectTimer) return
  170. reconnectTimer = setTimeout(() => {
  171. reconnectTimer = null
  172. initWebSocket()
  173. }, 3000)
  174. }
  175. // --- 4. 心跳保活 ---
  176. const startHeartbeat = () => {
  177. if (heartbeatTimer) clearInterval(heartbeatTimer)
  178. heartbeatTimer = setInterval(() => {
  179. if (socket && socket.readyState === WebSocket.OPEN) {
  180. socket.send('ping')
  181. }
  182. }, 10000)
  183. }
  184. // --- 生命周期 ---
  185. onMounted(async () => {
  186. try {
  187. // 1. 先拿列表 (只有价格,没有涨跌幅)
  188. const res = await GetCoins()
  189. coinList.value = Array.isArray(res) ? res : (res.data || [])
  190. // 2. 再连 WS (获取实时数据)
  191. if (coinList.value.length > 0) {
  192. initWebSocket()
  193. }
  194. } catch (error) {
  195. console.error('API 失败:', error)
  196. }
  197. })
  198. onUnmounted(() => {
  199. isUnmounted = true
  200. if (heartbeatTimer) clearInterval(heartbeatTimer)
  201. if (reconnectTimer) clearTimeout(reconnectTimer)
  202. if (socket) {
  203. socket.onclose = null
  204. socket.close()
  205. socket = null
  206. }
  207. })
  208. </script>
  209. <style lang="less" scoped>
  210. .hot-coin {
  211. margin-top: 20px;
  212. width: 346px;
  213. height: 333px;
  214. .coin-title {
  215. height: 24px;
  216. line-height: 24px;
  217. }
  218. .coin-head {
  219. display: flex;
  220. flex-direction: row;
  221. justify-content: flex-start;
  222. align-items: center;
  223. margin-top: 6px;
  224. width: 100%;
  225. height: 24px;
  226. .name {
  227. display: flex;
  228. flex-direction: row;
  229. justify-content: flex-start;
  230. align-items: center;
  231. height: 24px;
  232. .list-sort {
  233. display: flex;
  234. flex-direction: column;
  235. justify-content: flex-start;
  236. margin-left: 4px;
  237. width: 8px;
  238. height: 16px;
  239. .sort-up,
  240. .sort-bottom {
  241. display: flex;
  242. flex-direction: row;
  243. justify-content: center;
  244. align-items: center;
  245. width: 8px;
  246. height: 8px;
  247. img {
  248. width: 8px;
  249. height: 4px;
  250. }
  251. }
  252. }
  253. }
  254. .price {
  255. margin-left: 128px;
  256. }
  257. .today {
  258. margin-left: 47px;
  259. }
  260. }
  261. .coin-body {
  262. margin-top: 9px;
  263. width: 100%;
  264. .body-item {
  265. display: flex;
  266. flex-direction: row;
  267. justify-content: flex-start;
  268. align-items: center;
  269. margin-top: 23.5px;
  270. width: 100%;
  271. height: 38px;
  272. &:nth-child(1) {
  273. margin-top: 0;
  274. }
  275. .item-left {
  276. display: flex;
  277. flex-direction: row;
  278. justify-content: flex-start;
  279. align-items: center;
  280. width: 276px;
  281. height: 100%;
  282. .coin-img {
  283. width: 32px;
  284. height: 32px;
  285. img {
  286. width: 32px;
  287. height: 32px;
  288. }
  289. }
  290. .coin-name {
  291. margin-left: 10px;
  292. width: 85px;
  293. height: 34px;
  294. .upper-name {
  295. height: 20px;
  296. line-height: 20px;
  297. }
  298. .letter-name {
  299. height: 14px;
  300. line-height: 14px;
  301. }
  302. }
  303. .coin-echars {
  304. width: 60px;
  305. height: 35px;
  306. }
  307. .coin-price {
  308. margin-left: 13px;
  309. width: 75px;
  310. height: 38px;
  311. .upper-price {
  312. height: 20px;
  313. line-height: 20px;
  314. text-align: right;
  315. }
  316. .letter-price {
  317. height: 16px;
  318. line-height: 16px;
  319. text-align: right;
  320. }
  321. }
  322. }
  323. .item-right {
  324. margin-left: 8px;
  325. width: 61px;
  326. height: 25px;
  327. line-height: 25px;
  328. text-align: center;
  329. background: #45b26b;
  330. border-radius: 5px;
  331. }
  332. }
  333. }
  334. }
  335. //
  336. // //xin
  337. //.body-item {
  338. // display: flex;
  339. // justify-content: space-between;
  340. // align-items: center;
  341. // padding: 10px 15px;
  342. // border-bottom: 1px solid #f5f5f5;
  343. //}
  344. //.item-left {
  345. // display: flex;
  346. // align-items: center;
  347. // gap: 10px;
  348. //}
  349. //.coin-img img {
  350. // width: 32px;
  351. // height: 32px;
  352. // border-radius: 50%;
  353. // object-fit: cover;
  354. //}
  355. ///* .upper-name { font-weight: bold; font-size: 15px; color: #333; }
  356. //.letter-name { font-size: 12px; color: #999; }
  357. //.upper-price { font-weight: bold; font-size: 15px; margin-left: 10px; color: #333; } */
  358. //
  359. ///* 右侧涨跌幅按钮 */
  360. //.item-right {
  361. // padding: 6px 12px;
  362. // border-radius: 4px;
  363. // /* color: white; */
  364. // /* font-weight: 500;
  365. // font-size: 13px; */
  366. // min-width: 75px;
  367. // text-align: center;
  368. // transition: background-color 0.3s;
  369. //}
  370. /* 颜色配置 */
  371. .bg-green { background-color: #2EBD85; }
  372. .bg-red { background-color: #F6465D; }
  373. .bg-gray { background-color: #C0C0C0; }
  374. </style>