├── src ├── pkg │ ├── utils │ │ ├── market.ts │ │ ├── number.ts │ │ └── time.ts │ ├── ratio │ │ └── ratio.ts │ ├── xhttp │ │ └── xhttp.ts │ └── xws │ │ └── xws.ts ├── assets │ ├── swift.jpg │ ├── eipi-star.png │ ├── main.css │ ├── base.css │ └── theme.scss ├── views │ └── trade │ │ ├── main │ │ ├── components │ │ │ ├── order.vue │ │ │ ├── chart │ │ │ │ ├── value.vue │ │ │ │ └── chart.vue │ │ │ ├── orderbook │ │ │ │ ├── quote.vue │ │ │ │ ├── bidQuote.vue │ │ │ │ └── askQuote.vue │ │ │ ├── trade │ │ │ │ ├── tradeLimit.vue │ │ │ │ ├── tradeMarket.vue │ │ │ │ ├── tradeMarketItem.vue │ │ │ │ └── tradeLimitItem.vue │ │ │ ├── trade.vue │ │ │ ├── filledOrder.vue │ │ │ ├── chart.vue │ │ │ ├── orderbook.vue │ │ │ └── market.vue │ │ └── index.vue │ │ ├── footer │ │ ├── index.vue │ │ └── components │ │ │ ├── historicalOrder.vue │ │ │ └── currentOrder.vue │ │ ├── index.vue │ │ └── header │ │ └── index.vue ├── api │ ├── trade.ts │ ├── user.ts │ ├── ticker.ts │ ├── market.ts │ ├── depth.ts │ ├── balance.ts │ ├── kline.ts │ ├── operation.ts │ └── order.ts ├── components │ ├── icons │ │ ├── IconSupport.vue │ │ ├── IconTooling.vue │ │ ├── IconCommunity.vue │ │ ├── IconDocumentation.vue │ │ └── IconEcosystem.vue │ ├── menu │ │ ├── menu.ts │ │ └── MenuView.vue │ ├── HelloWorld.vue │ ├── WelcomeItem.vue │ ├── TheWelcome.vue │ └── MobileNav.vue ├── stores │ ├── counter.ts │ ├── userStore.ts │ └── operationStore.ts ├── router │ └── index.ts ├── main.ts ├── App.vue └── singleton │ └── wsClient.ts ├── env.d.ts ├── public └── favicon.ico ├── resource └── images │ └── eipistar.png ├── .env.dev ├── .env.prod ├── auto-imports.d.ts ├── .eslintrc.cjs ├── tsconfig.app.json ├── index.html ├── tsconfig.node.json ├── vite.config.ts ├── tsconfig.json ├── components.d.ts ├── package.json └── README.md /src/pkg/utils/market.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sevtin/coinex/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/swift.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sevtin/coinex/HEAD/src/assets/swift.jpg -------------------------------------------------------------------------------- /src/assets/eipi-star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sevtin/coinex/HEAD/src/assets/eipi-star.png -------------------------------------------------------------------------------- /resource/images/eipistar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sevtin/coinex/HEAD/resource/images/eipistar.png -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | NODE_ENV="development" 2 | VITE_BASE_API="http://localhost:8088" 3 | VITE_WS_URL="ws://127.0.0.1:6501/ws" 4 | -------------------------------------------------------------------------------- /.env.prod: -------------------------------------------------------------------------------- 1 | NODE_ENV="development" 2 | VITE_BASE_API="https://eipistar.dpdns.org" 3 | VITE_WS_URL="wss://eipistar.dpdns.org/ws" -------------------------------------------------------------------------------- /src/views/trade/main/components/order.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /src/pkg/ratio/ratio.ts: -------------------------------------------------------------------------------- 1 | export const quantity_ratio: number = 1000000.0; 2 | export const price_ratio: number = 100.0; 3 | export const amount_ratio: number = quantity_ratio * price_ratio; 4 | -------------------------------------------------------------------------------- /src/views/trade/main/components/chart/value.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | export {} 7 | declare global { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/api/trade.ts: -------------------------------------------------------------------------------- 1 | // {"topic":"BTCUSDT@TRADE","data":[[1,1710748589,20,10,0]]} 2 | 3 | export interface Trade { 4 | time: string 5 | price: string 6 | amount: string 7 | direction: number 8 | color:string 9 | } 10 | 11 | // number[][] -------------------------------------------------------------------------------- /src/api/user.ts: -------------------------------------------------------------------------------- 1 | import xhttp from "@/pkg/xhttp/xhttp"; 2 | import bigInt from 'big-integer'; 3 | 4 | 5 | export interface User { 6 | uid: bigInt.BigInteger; 7 | } 8 | 9 | export function createGuest() { 10 | return xhttp.post("/open/user/create_guest") 11 | } 12 | -------------------------------------------------------------------------------- /src/api/ticker.ts: -------------------------------------------------------------------------------- 1 | // {"topic":"BTCUSDT@TICKER_1440","data":{"market_id":1,"symbol":"BTCUSDT","intvl":1440,"values":[1710691200,20,20,20,20,20,400]}} 2 | 3 | export interface Ticker { 4 | market_id: number; 5 | symbol: string; 6 | intvl: number; 7 | values: any[]; 8 | } 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/icons/IconSupport.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/stores/counter.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed } from 'vue' 2 | import { defineStore } from 'pinia' 3 | 4 | export const useCounterStore = defineStore('counter', () => { 5 | const count = ref(0) 6 | const doubleCount = computed(() => count.value * 2) 7 | function increment() { 8 | count.value++ 9 | } 10 | 11 | return { count, doubleCount, increment } 12 | }) 13 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import TradeView from '../views/trade/index.vue' 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(import.meta.env.BASE_URL), 6 | routes: [ 7 | { 8 | path: '/', 9 | name: 'trade', 10 | component: TradeView, 11 | } 12 | ] 13 | }) 14 | 15 | export default router 16 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | 'extends': [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript', 10 | '@vue/eslint-config-prettier/skip-formatting' 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 'latest' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 8 | 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | eipistar 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/api/market.ts: -------------------------------------------------------------------------------- 1 | import {Subscribe} from "@/pkg/xws/xws"; 2 | 3 | export interface Market { 4 | market_id: number; 5 | symbol: string; 6 | intvl: number; 7 | time: string; 8 | open: string; 9 | high: string; 10 | low: string; 11 | close: string; 12 | vol: string; 13 | tor: string; 14 | change: string; 15 | color: string; 16 | pre_close: string; 17 | close_val: number; 18 | pre_close_val: number; 19 | } -------------------------------------------------------------------------------- /src/api/depth.ts: -------------------------------------------------------------------------------- 1 | // {"topic":"BTCUSDT@DEPTH_5","data":{"ts":1710746280,"market_id":1,"symbol":"BTCUSDT","levels":5,"bids":[[20,9540],[19,1200],[18,12]],"asks":null}} 2 | export interface Depth { 3 | ts: number;//时间戳 4 | market_id: number; 5 | symbol: string; 6 | levels: number; 7 | bids: string[][]; 8 | asks: string[][]; 9 | } 10 | 11 | export interface DepthItem { 12 | ts: number;//时间戳 13 | price: string 14 | amount: string 15 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/api/balance.ts: -------------------------------------------------------------------------------- 1 | import xhttp from "@/pkg/xhttp/xhttp"; 2 | import bigInt from "big-integer"; 3 | 4 | export interface BalanceResp { 5 | code: number; 6 | data: Balance[]; 7 | msg: string; 8 | } 9 | 10 | export interface Balance { 11 | wallet_id: bigInt.BigInteger; 12 | cur_id: number; 13 | balance: string; 14 | frozen_amt: string; 15 | status: number; 16 | cur_name: string; 17 | } 18 | 19 | export function GetBalances() { 20 | return xhttp.get("/api/user/balance") 21 | } -------------------------------------------------------------------------------- /src/components/menu/menu.ts: -------------------------------------------------------------------------------- 1 | export interface IMenu { 2 | Id: number 3 | Color: string 4 | SelectedColor: string 5 | Text: string 6 | FontSize: string 7 | Selected: boolean 8 | Entered: boolean 9 | Path: string 10 | } 11 | 12 | 13 | function newMenu(id: number, name: string, path: string): IMenu { 14 | return { 15 | Id: id, 16 | Color: "#ffffff", 17 | SelectedColor: "#ffffff", 18 | Text: name, 19 | FontSize: "14px", 20 | Selected: false, 21 | Entered: false, 22 | Path: path 23 | } 24 | } -------------------------------------------------------------------------------- /src/stores/userStore.ts: -------------------------------------------------------------------------------- 1 | import {defineStore} from 'pinia' 2 | import Cookies from 'js-cookie' 3 | import type {User} from "@/api/user"; 4 | import {createGuest} from "@/api/user"; 5 | 6 | export const useUserStore = defineStore('user', { 7 | state: (): User => ({ 8 | signed: Boolean(Cookies.get('signed')), 9 | }), 10 | getters: { 11 | isSigned(): boolean { 12 | return this.signed 13 | } 14 | }, 15 | actions: { 16 | setSigned(signed: boolean) { 17 | this.signed = signed 18 | }, 19 | }, 20 | persist: true 21 | }) 22 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // import './assets/main.css' 2 | import './assets/theme.scss' 3 | 4 | import {createApp} from 'vue' 5 | import {createPinia} from 'pinia' 6 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; 7 | import ElTableInfiniteScroll from "el-table-infinite-scroll"; 8 | import 'element-plus/dist/index.css' 9 | 10 | import App from './App.vue' 11 | import router from './router' 12 | 13 | const app = createApp(App) 14 | 15 | const pinia = createPinia(); 16 | pinia.use(piniaPluginPersistedstate); 17 | app.use(pinia) 18 | 19 | app.use(ElTableInfiniteScroll); 20 | 21 | app.use(router) 22 | 23 | app.mount('#app') 24 | -------------------------------------------------------------------------------- /src/api/kline.ts: -------------------------------------------------------------------------------- 1 | import bigInt from "big-integer"; 2 | import xhttp from "@/pkg/xhttp/xhttp"; 3 | import type {User} from "@/api/user"; 4 | 5 | // {"topic":"BTCUSDT@KLINE_1","data":[[1710748560,20,20,20,20,10,200]]} 6 | 7 | export interface KlineInfo { 8 | ts: number; 9 | open: string; 10 | high: string; 11 | low: string; 12 | close: string; 13 | vol: string; 14 | tor: string; 15 | } 16 | 17 | export interface KlineResp { 18 | code: number 19 | msg: string 20 | data:KlineInfo[] 21 | } 22 | 23 | export function GetKlines(params?: object) { 24 | return xhttp.get("/api/market/klines", params) 25 | } -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | 3 | #app { 4 | max-width: 1280px; 5 | margin: 0 auto; 6 | padding: 2rem; 7 | font-weight: normal; 8 | } 9 | 10 | a, 11 | .green { 12 | text-decoration: none; 13 | color: hsla(160, 100%, 37%, 1); 14 | transition: 0.4s; 15 | padding: 3px; 16 | } 17 | 18 | @media (hover: hover) { 19 | a:hover { 20 | background-color: hsla(160, 100%, 37%, 0.2); 21 | } 22 | } 23 | 24 | @media (min-width: 1024px) { 25 | body { 26 | display: flex; 27 | place-items: center; 28 | } 29 | 30 | #app { 31 | display: grid; 32 | grid-template-columns: 1fr 1fr; 33 | padding: 0 2rem; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/pkg/utils/number.ts: -------------------------------------------------------------------------------- 1 | 2 | export function toPercentage(num: number): string { 3 | // 将数字乘以 100 并保留两位小数 4 | const percentage = (num * 100).toFixed(2); 5 | return `${percentage}%`; 6 | } 7 | export function stringToNumber(str) { 8 | // 将字符串转换为浮点数 9 | let num = parseFloat(str); 10 | // 如果转换失败,返回NaN 11 | if (isNaN(num)) { 12 | return 0; 13 | } 14 | // 格式化浮点数,保留最多8位小数 15 | return parseFloat(num.toFixed(8)); 16 | } 17 | 18 | export function stringToPrice(str) { 19 | // 将字符串转换为浮点数 20 | let num = parseFloat(str); 21 | // 如果转换失败,返回NaN 22 | if (isNaN(num)) { 23 | return 0; 24 | } 25 | // 格式化浮点数,保留最多2位小数 26 | return parseFloat(num.toFixed(2)); 27 | } -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 42 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import AutoImport from 'unplugin-auto-import/vite' 5 | import Components from 'unplugin-vue-components/vite' 6 | import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' 7 | import vue from '@vitejs/plugin-vue' 8 | import vueJsx from '@vitejs/plugin-vue-jsx' 9 | import VueDevTools from 'vite-plugin-vue-devtools' 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig({ 13 | plugins: [ 14 | vue(), 15 | AutoImport({ 16 | resolvers: [ElementPlusResolver()], 17 | }), 18 | Components({ 19 | resolvers: [ElementPlusResolver({ importStyle: "sass" })], 20 | }), 21 | vueJsx(), 22 | VueDevTools(), 23 | ], 24 | resolve: { 25 | alias: { 26 | '@': fileURLToPath(new URL('./src', import.meta.url)) 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/menu/MenuView.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 36 | 37 | 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ], 11 | "compilerOptions": { 12 | "target": "ES2022", 13 | "useDefineForClassFields": true, 14 | "module": "ESNext", 15 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 16 | "skipLibCheck": true, 17 | 18 | /* Bundler mode */ 19 | "moduleResolution": "bundler", 20 | "allowImportingTsExtensions": true, 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "jsx": "preserve", 25 | 26 | /* Linting */ 27 | "strict": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "noFallthroughCasesInSwitch": true, 31 | 32 | "baseUrl": ".", 33 | "paths": { 34 | "@/*": ["src/*"] 35 | } 36 | }, 37 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] 38 | } 39 | -------------------------------------------------------------------------------- /src/components/icons/IconTooling.vue: -------------------------------------------------------------------------------- 1 | 2 | 20 | -------------------------------------------------------------------------------- /src/components/icons/IconCommunity.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/views/trade/main/components/orderbook/quote.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 31 | 32 | -------------------------------------------------------------------------------- /src/api/operation.ts: -------------------------------------------------------------------------------- 1 | export interface Operation { 2 | mrket: Market; 3 | ticker: Ticker; 4 | trade: Trade; 5 | depth: Depth; 6 | kline: Kline; 7 | order: Order; 8 | } 9 | 10 | export interface Market { 11 | intvl: number; 12 | } 13 | 14 | export interface Ticker { 15 | market_id: number; 16 | symbol: string; 17 | intvl: number; 18 | open: string; 19 | close: string; 20 | color: number; 21 | } 22 | 23 | export interface Trade { 24 | symbol: string; 25 | } 26 | 27 | export interface Depth { 28 | symbol: string; 29 | levels: number; 30 | } 31 | 32 | export interface Kline { 33 | symbol: string; 34 | intvl: number; 35 | } 36 | 37 | export interface Order { 38 | version: number; 39 | } 40 | 41 | 42 | // type MarketHandler = (intvl: number, sub: Subscribe) => void; 43 | // type TickerHandler = (symbol: string, intvl: number, sub: Subscribe) => void; 44 | // type TradeHandler = (symbol: string, sub: Subscribe) => void; 45 | // type DepthHandler = (symbol: string, levels: number, sub: Subscribe) => void; 46 | // type KlineHandler = (symbol: string, intvl: number, sub: Subscribe) => void; -------------------------------------------------------------------------------- /src/views/trade/main/components/orderbook/bidQuote.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 28 | 29 | -------------------------------------------------------------------------------- /src/components/icons/IconDocumentation.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/views/trade/main/components/trade/tradeLimit.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 38 | 39 | 61 | -------------------------------------------------------------------------------- /src/views/trade/main/components/trade/tradeMarket.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 38 | 39 | 61 | -------------------------------------------------------------------------------- /src/views/trade/main/components/orderbook/askQuote.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | 30 | -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // Generated by unplugin-vue-components 5 | // Read more: https://github.com/vuejs/core/pull/3399 6 | export {} 7 | 8 | declare module 'vue' { 9 | export interface GlobalComponents { 10 | ElButton: typeof import('element-plus/es')['ElButton'] 11 | ElIcon: typeof import('element-plus/es')['ElIcon'] 12 | ElTable: typeof import('element-plus/es')['ElTable'] 13 | ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] 14 | ElTabPane: typeof import('element-plus/es')['ElTabPane'] 15 | ElTabs: typeof import('element-plus/es')['ElTabs'] 16 | HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] 17 | IconCommunity: typeof import('./src/components/icons/IconCommunity.vue')['default'] 18 | IconDocumentation: typeof import('./src/components/icons/IconDocumentation.vue')['default'] 19 | IconEcosystem: typeof import('./src/components/icons/IconEcosystem.vue')['default'] 20 | IconSupport: typeof import('./src/components/icons/IconSupport.vue')['default'] 21 | IconTooling: typeof import('./src/components/icons/IconTooling.vue')['default'] 22 | MenuView: typeof import('./src/components/menu/MenuView.vue')['default'] 23 | MobileNav: typeof import('./src/components/MobileNav.vue')['default'] 24 | RouterLink: typeof import('vue-router')['RouterLink'] 25 | RouterView: typeof import('vue-router')['RouterView'] 26 | TheWelcome: typeof import('./src/components/TheWelcome.vue')['default'] 27 | WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default'] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/api/order.ts: -------------------------------------------------------------------------------- 1 | import xhttp from "@/pkg/xhttp/xhttp"; 2 | 3 | export interface Order { 4 | market_id: number; // 市场ID 5 | order_type: number; // 1: Limit, 2: Market 6 | side: number; // 1: Buy, 2: Sell 7 | price: number; // 价格 8 | qty: number; // 数量 9 | } 10 | 11 | export interface OrderInfo { 12 | order_id: bigInt.BigInteger; 13 | market_id: number; 14 | uid: bigint; 15 | side: number; 16 | order_type: number; 17 | order_status: number; 18 | price: string; 19 | unfilled_qty: string; 20 | filled_qty: string; 21 | filled_amt: string; 22 | fee_rate: number; 23 | fee: number; 24 | ver: number; 25 | created_ts: number; 26 | symbol: string; 27 | ts_str: string; 28 | side_str: string; 29 | order_type_str: string; 30 | order_status_str: string; 31 | 32 | price_str: string; 33 | qty_str: string; 34 | } 35 | 36 | enum Side { 37 | Unknown = 0, 38 | Buy = 1, 39 | Sell = 2 40 | } 41 | 42 | enum OrderType { 43 | Unknown = 0, 44 | Limit = 1, 45 | Market = 2, 46 | StopLoss = 3, 47 | StopLossLimit = 4, 48 | TakeProfit = 5, 49 | LimitTakeProfit = 6, 50 | LimitMaker = 7 51 | } 52 | 53 | enum OrderStatus { 54 | Pending = 0, 55 | PartiallyFilled = 1, 56 | FullyFilled = 2, 57 | Canceled = 3, 58 | Rejected = 4 59 | } 60 | 61 | export function createOrder(params?: object) { 62 | return xhttp.post("/api/order/create", params) 63 | } 64 | 65 | export function cancelOrder(params?: object) { 66 | return xhttp.post("/api/order/cancel", params) 67 | } 68 | 69 | export function orderList(params?: object) { 70 | return xhttp.get("/api/order/list", params) 71 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coinex", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --mode dev", 8 | "prod": "vite --mode prod", 9 | "build:dev": "vite build --mode dev", 10 | "build:prod": "vite build --mode prod", 11 | "preview": "vite preview", 12 | "build-only": "vite build", 13 | "type-check": "vue-tsc --build --force", 14 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", 15 | "format": "prettier --write src/" 16 | }, 17 | "dependencies": { 18 | "@types/js-cookie": "^3.0.6", 19 | "big-integer": "^1.6.52", 20 | "el-table-infinite-scroll": "^3.0.3", 21 | "element-plus": "^2.6.1", 22 | "js-cookie": "^3.0.5", 23 | "json-bigint": "^1.0.0", 24 | "lightweight-charts": "^4.1.3", 25 | "lodash": "^4.17.21", 26 | "pinia": "^2.1.7", 27 | "pinia-plugin-persistedstate": "^3.2.1", 28 | "vue": "^3.4.21", 29 | "vue-router": "^4.3.0" 30 | }, 31 | "devDependencies": { 32 | "@rushstack/eslint-patch": "^1.3.3", 33 | "@tsconfig/node20": "^20.1.2", 34 | "@types/axios": "^0.14.0", 35 | "@types/node": "^20.11.25", 36 | "@vitejs/plugin-vue": "^5.0.4", 37 | "@vitejs/plugin-vue-jsx": "^3.1.0", 38 | "@vue/eslint-config-prettier": "^8.0.0", 39 | "@vue/eslint-config-typescript": "^12.0.0", 40 | "@vue/tsconfig": "^0.5.1", 41 | "eslint": "^8.49.0", 42 | "eslint-plugin-vue": "^9.17.0", 43 | "npm-run-all2": "^6.1.2", 44 | "prettier": "^3.0.3", 45 | "sass": "^1.72.0", 46 | "typescript": "~5.4.0", 47 | "unplugin-auto-import": "^0.17.5", 48 | "unplugin-vue-components": "^0.26.0", 49 | "vite": "^5.1.5", 50 | "vite-plugin-vue-devtools": "^7.0.16", 51 | "vue-tsc": "^2.0.6" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/WelcomeItem.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 88 | -------------------------------------------------------------------------------- /src/components/icons/IconEcosystem.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Golang + Vue3 加密币模拟交易所 2 | 3 | 从2024年9月至今无错单/无漏单 4 | 5 | 线上地址:https://eipistar.dpdns.org/ 6 | 7 | ![eipistar.png](resource%2Fimages%2Feipistar.png) 8 | 9 | #### 本地运行步骤: 10 | 11 | 1、安装依赖:npm install 12 | 13 | 2、运行项目:npm run prod 14 | 15 | 3、处理cookie跨域:打开控制台,修改Cookies的domain。将eipistar.dpdns.org修改为localhost,刷新当前页面即可。 16 | 17 | ### 后端架构图: 18 |
19 | swift 20 |
21 | 22 | ### 撮合交易引擎基准测试 23 | #### int64版本基准测试 24 | ``` 25 | saeipi@saeipi xengine % go test -bench=. 26 | goos: darwin 27 | goarch: arm64 28 | pkg: lark/pkg/common/xengine 29 | BenchmarkSubmitBuyOrders-8 5645808 223.4 ns/op 30 | BenchmarkSubmitSellOrders-8 6197025 232.9 ns/op 31 | BenchmarkOrderMatching-8 6565600 223.4 ns/op 32 | BenchmarkCancelOrders-8 45432530 23.44 ns/op 33 | BenchmarkBulkOrderProcessing-8 4856690 248.6 ns/op 34 | BenchmarkOrderbook5kLevelsRandomInsert-8 13577226 75.25 ns/op 35 | BenchmarkOrderbook10kLevelsRandomInsert-8 15151626 82.45 ns/op 36 | BenchmarkOrderbook20kLevelsRandomInsert-8 12119716 83.93 ns/op 37 | PASS 38 | ok lark/pkg/common/xengine 12.553s 39 | ``` 40 | 41 | #### decimal版本基准测试 42 | ``` 43 | saeipi@saeipi xengine % go test -bench=. 44 | goos: darwin 45 | goarch: arm64 46 | pkg: lark/pkg/common/xengine 47 | BenchmarkSubmitBuyOrders-8 2103273 569.8 ns/op 48 | BenchmarkSubmitSellOrders-8 2244198 599.9 ns/op 49 | BenchmarkOrderMatching-8 2139739 619.8 ns/op 50 | BenchmarkCancelOrders-8 9421946 125.2 ns/op 51 | BenchmarkBulkOrderProcessing-8 666296 1670 ns/op 52 | BenchmarkOrderbook5kLevelsRandomInsert-8 2632840 424.6 ns/op 53 | BenchmarkOrderbook10kLevelsRandomInsert-8 2358277 575.8 ns/op 54 | BenchmarkOrderbook20kLevelsRandomInsert-8 1819166 703.7 ns/op 55 | PASS 56 | ok lark/pkg/common/xengine 15.954s 57 | ``` 58 | -------------------------------------------------------------------------------- /src/views/trade/main/components/trade.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 25 | 26 | -------------------------------------------------------------------------------- /src/assets/base.css: -------------------------------------------------------------------------------- 1 | /* color palette from */ 2 | :root { 3 | --vt-c-white: #ffffff; 4 | --vt-c-white-soft: #f8f8f8; 5 | --vt-c-white-mute: #f2f2f2; 6 | 7 | --vt-c-black: #181818; 8 | --vt-c-black-soft: #222222; 9 | --vt-c-black-mute: #282828; 10 | 11 | --vt-c-indigo: #2c3e50; 12 | 13 | --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); 14 | --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); 15 | --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); 16 | --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); 17 | 18 | --vt-c-text-light-1: var(--vt-c-indigo); 19 | --vt-c-text-light-2: rgba(60, 60, 60, 0.66); 20 | --vt-c-text-dark-1: var(--vt-c-white); 21 | --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); 22 | } 23 | 24 | /* semantic color variables for this project */ 25 | :root { 26 | --color-background: var(--vt-c-white); 27 | --color-background-soft: var(--vt-c-white-soft); 28 | --color-background-mute: var(--vt-c-white-mute); 29 | 30 | --color-border: var(--vt-c-divider-light-2); 31 | --color-border-hover: var(--vt-c-divider-light-1); 32 | 33 | --color-heading: var(--vt-c-text-light-1); 34 | --color-text: var(--vt-c-text-light-1); 35 | 36 | --section-gap: 160px; 37 | } 38 | 39 | @media (prefers-color-scheme: dark) { 40 | :root { 41 | --color-background: var(--vt-c-black); 42 | --color-background-soft: var(--vt-c-black-soft); 43 | --color-background-mute: var(--vt-c-black-mute); 44 | 45 | --color-border: var(--vt-c-divider-dark-2); 46 | --color-border-hover: var(--vt-c-divider-dark-1); 47 | 48 | --color-heading: var(--vt-c-text-dark-1); 49 | --color-text: var(--vt-c-text-dark-2); 50 | } 51 | } 52 | 53 | *, 54 | *::before, 55 | *::after { 56 | box-sizing: border-box; 57 | margin: 0; 58 | font-weight: normal; 59 | } 60 | 61 | body { 62 | min-height: 100vh; 63 | color: var(--color-text); 64 | background: var(--color-background); 65 | transition: 66 | color 0.5s, 67 | background-color 0.5s; 68 | line-height: 1.6; 69 | font-family: 70 | Inter, 71 | -apple-system, 72 | BlinkMacSystemFont, 73 | 'Segoe UI', 74 | Roboto, 75 | Oxygen, 76 | Ubuntu, 77 | Cantarell, 78 | 'Fira Sans', 79 | 'Droid Sans', 80 | 'Helvetica Neue', 81 | sans-serif; 82 | font-size: 15px; 83 | text-rendering: optimizeLegibility; 84 | -webkit-font-smoothing: antialiased; 85 | -moz-osx-font-smoothing: grayscale; 86 | } 87 | -------------------------------------------------------------------------------- /src/pkg/utils/time.ts: -------------------------------------------------------------------------------- 1 | export function timestampToHourMinute(timestampSeconds: number): string { 2 | const options = { 3 | timeZone: 'UTC', 4 | hour: '2-digit', 5 | minute: '2-digit', 6 | }; 7 | const date = new Date(timestampSeconds * 1000); // 将秒转换为毫秒 8 | // 使用 Intl.DateTimeFormat 对象格式化时间 9 | return Intl.DateTimeFormat('en-US', options).format(date); 10 | 11 | // const date = new Date(timestampSeconds * 1000); // 将秒转换为毫秒 12 | // const hours = String(date.getHours()).padStart(2, '0'); // 获取小时,并添加前导零 13 | // const minutes = String(date.getMinutes()).padStart(2, '0'); // 获取分钟,并添加前导零 14 | // return `${hours}:${minutes}`; // 返回时分字符串 15 | } 16 | 17 | export function timestampToHourMinuteSecond(timestampSeconds: number): string { 18 | const options = { 19 | timeZone: 'UTC', 20 | hour: '2-digit', 21 | minute: '2-digit', 22 | second: '2-digit', 23 | hour12: false, // 添加此行以使用24小时制 24 | }; 25 | const date = new Date(timestampSeconds * 1000); // 将秒转换为毫秒 26 | // 使用 Intl.DateTimeFormat 对象格式化时间 27 | return Intl.DateTimeFormat('en-US', options).format(date); 28 | 29 | // const date = new Date(timestampSeconds * 1000); // 将秒转换为毫秒 30 | // const hours = String(date.getHours()).padStart(2, '0'); // 获取小时,并添加前导零 31 | // const minutes = String(date.getMinutes()).padStart(2, '0'); // 获取分钟,并添加前导零 32 | // const seconds = String(date.getSeconds()).padStart(2, '0'); // 获取分钟,并添加前导零 33 | // return `${hours}:${minutes}:${seconds}`; // 返回时分字符串 34 | } 35 | 36 | export function timestampFormat(timestampSeconds: number): string { 37 | const date = new Date(timestampSeconds); // 将秒转换为毫秒 38 | // 设置时区为 "Europe/London" 39 | const options = { 40 | timeZone: 'UTC', 41 | year: 'numeric', 42 | month: '2-digit', 43 | day: '2-digit', 44 | hour: '2-digit', 45 | minute: '2-digit', 46 | second: '2-digit', 47 | }; 48 | // 使用 Intl.DateTimeFormat 对象格式化时间 49 | return new Intl.DateTimeFormat('en-US', options).format(date); 50 | // return date.toLocaleString(); 51 | } 52 | 53 | export function timestampBarFormat(timestampSeconds: number): string { 54 | const date = new Date(timestampSeconds); // 将秒转换为毫秒 55 | // 设置时区为 "Europe/London" 56 | const options = { 57 | timeZone: 'UTC', 58 | month: '2-digit', 59 | day: '2-digit', 60 | hour: '2-digit', 61 | minute: '2-digit', 62 | hour12: false, // 添加此行以使用24小时制 63 | }; 64 | // 使用 Intl.DateTimeFormat 对象格式化时间 65 | return new Intl.DateTimeFormat('en-US', options).format(date); 66 | // return date.toLocaleString(); 67 | } 68 | 69 | function gettimeZone() { 70 | return Intl.DateTimeFormat().resolvedOptions().timeZone 71 | } -------------------------------------------------------------------------------- /src/pkg/xhttp/xhttp.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import JSONbig from 'json-bigint'; 3 | import type {AxiosInstance, AxiosRequestConfig} from 'axios' 4 | import {ElMessage} from "element-plus"; 5 | 6 | 7 | // 定义结果接口Result 8 | export interface Result { 9 | message: string 10 | code: number 11 | data: T 12 | } 13 | 14 | class HttpClient { 15 | private instance: AxiosInstance; 16 | 17 | constructor() { 18 | this.instance = axios.create({ 19 | baseURL: import.meta.env.VITE_BASE_API, 20 | timeout: 60000, // 设置请求超时时间, 21 | withCredentials: true, 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | }, 25 | // 使用transformResponse选项将响应数据进行自定义处理 26 | transformResponse: [(data) => { 27 | try { 28 | // 作用1:把json字符串转为js对象 29 | // 作用2:把里面的大数字做安全处理 30 | return JSONbig.parse(data) 31 | } catch (e) { 32 | return data 33 | } 34 | }] 35 | }); 36 | this.setupInterceptors() 37 | } 38 | 39 | private setupInterceptors() { 40 | this.instance.interceptors.request.use( 41 | (config) => { 42 | return config; 43 | }, 44 | (error) => Promise.reject(error) 45 | ); 46 | this.instance.interceptors.response.use( 47 | (response) => { 48 | /* 49 | if (response.data.code > 200) { 50 | ElMessage({ 51 | message: data.message || '服务器返回异常', 52 | type: 'warning', 53 | }); 54 | } 55 | */ 56 | return response 57 | }, 58 | (error) => { 59 | /* 60 | ElMessage({ 61 | message: '网络请求失败,请稍后再试', 62 | type: 'error', 63 | }); 64 | */ 65 | Promise.reject(error) 66 | } 67 | ); 68 | } 69 | 70 | private async request(config: AxiosRequestConfig): Promise> { 71 | try { 72 | const response = await this.instance.request>>(config); 73 | return response.data; 74 | } catch (error) { 75 | return Promise.reject(error); 76 | } 77 | } 78 | 79 | get(url: string, params?: object): Promise> { 80 | return this.request({ 81 | method: 'get', 82 | url, 83 | params 84 | }); 85 | } 86 | 87 | post(url: string, data?: object): Promise> { 88 | return this.request({ 89 | method: 'post', 90 | url, 91 | data 92 | }); 93 | } 94 | } 95 | 96 | export default new HttpClient(); 97 | -------------------------------------------------------------------------------- /src/stores/operationStore.ts: -------------------------------------------------------------------------------- 1 | import {defineStore} from 'pinia' 2 | import {Operation, Market, Ticker, Trade, Depth, Kline, Order} from "@/api/operation"; 3 | import type {Balance} from "@/api/balance"; 4 | import {BalanceResp, GetBalances} from "@/api/balance"; 5 | 6 | export const useOperationStore = defineStore('operation', { 7 | state: (): Operation => ({ 8 | mrket: {} as Market, 9 | ticker: {} as Ticker, 10 | trade: {} as Trade, 11 | depth: {} as Depth, 12 | kline: {} as Kline, 13 | order: {version: 0} as Order, 14 | Balances: [] as Balance[] 15 | }), 16 | getters: { 17 | getMarket(): Market { 18 | return this.mrket 19 | }, 20 | getTicker(): Ticker { 21 | return this.ticker 22 | }, 23 | getTrade(): Trade { 24 | return this.trade 25 | }, 26 | getDepth(): Depth { 27 | return this.depth 28 | }, 29 | getKline(): Kline { 30 | return this.kline 31 | }, 32 | getOrder(): number { 33 | return this.order 34 | }, 35 | getBalances(): Balance[] { 36 | return this.Balances 37 | } 38 | }, 39 | actions: { 40 | setMarket(intvl: number) { 41 | this.$state.mrket.intvl = intvl 42 | }, 43 | setTicker(market_id: number, symbol: string, intvl: number, open: string, close: string, color: string) { 44 | this.$state.ticker.market_id = market_id; 45 | this.$state.ticker.symbol = symbol; 46 | this.$state.ticker.intvl = intvl; 47 | this.$state.ticker.open = open; 48 | this.$state.ticker.close = close 49 | this.$state.ticker.color = color; 50 | 51 | this.$state.trade.symbol = symbol; 52 | this.$state.depth.symbol = symbol; 53 | this.$state.kline.symbol = symbol; 54 | }, 55 | setTickerPrice(price: string) { 56 | this.$state.ticker.close = price; 57 | }, 58 | setTickerColor(color: string) { 59 | this.$state.ticker.color = color; 60 | }, 61 | setDepth(levels: number) { 62 | this.$state.depth.levels = levels; 63 | }, 64 | setKlineIntvl(intvl: number) { 65 | this.$state.kline.intvl = intvl; 66 | }, 67 | updateOrderVersion() { 68 | this.$state.order.version++; 69 | }, 70 | updateBalances() { 71 | GetBalances().then((resp: BalanceResp) => { 72 | if (resp.data.length>0) { 73 | this.$patch((state) => { 74 | state.Balances = resp.data; 75 | }); 76 | } 77 | console.log(resp) 78 | }).catch((err) => { 79 | console.log(err) 80 | }) 81 | } 82 | }, 83 | persist: false, 84 | deep: true 85 | }) 86 | -------------------------------------------------------------------------------- /src/views/trade/footer/index.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 41 | 42 | 108 | -------------------------------------------------------------------------------- /src/views/trade/index.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 61 | 62 | -------------------------------------------------------------------------------- /src/components/TheWelcome.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 89 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 34 | 35 | 45 | 46 | 164 | -------------------------------------------------------------------------------- /src/components/MobileNav.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 70 | 71 | -------------------------------------------------------------------------------- /src/views/trade/main/components/filledOrder.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 83 | 84 | 164 | -------------------------------------------------------------------------------- /src/views/trade/main/components/trade/tradeMarketItem.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 96 | 97 | 181 | -------------------------------------------------------------------------------- /src/views/trade/main/components/chart.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 81 | 82 | -------------------------------------------------------------------------------- /src/views/trade/footer/components/historicalOrder.vue: -------------------------------------------------------------------------------- 1 | 97 | 98 | 120 | 121 | -------------------------------------------------------------------------------- /src/views/trade/main/components/trade/tradeLimitItem.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 123 | 124 | 208 | -------------------------------------------------------------------------------- /src/singleton/wsClient.ts: -------------------------------------------------------------------------------- 1 | import {WebSocketClient} from "@/pkg/xws/xws"; 2 | import type {Subscribe} from "@/pkg/xws/xws"; 3 | import {Message} from "@/pkg/xws/xws"; 4 | 5 | 6 | type MarketHandler = (intvl: number, sub: Subscribe) => void; 7 | type TickerHandler = (symbol: string, intvl: number, sub: Subscribe) => void; 8 | type TradeHandler = (symbol: string, sub: Subscribe) => void; 9 | type DepthHandler = (symbol: string, levels: number, sub: Subscribe) => void; 10 | type KlineHandler = (symbol: string, intvl: number, sub: Subscribe) => void; 11 | 12 | 13 | class Singleton { 14 | private static instance: Singleton; 15 | private client: WebSocketClient 16 | 17 | private marketHandler: Map 18 | 19 | private tickerHandler: Map 20 | 21 | private tradeHandler: Map 22 | 23 | private depthHandler: Map 24 | 25 | private klineHandler: Map 26 | 27 | 28 | private constructor() { 29 | } 30 | 31 | public static getInstance(): Singleton { 32 | if (!Singleton.instance) { 33 | Singleton.instance = new Singleton(); 34 | Singleton.instance.marketHandler = new Map; 35 | Singleton.instance.tickerHandler = new Map 36 | Singleton.instance.tradeHandler = new Map 37 | Singleton.instance.depthHandler = new Map 38 | Singleton.instance.klineHandler = new Map 39 | } 40 | return Singleton.instance; 41 | } 42 | 43 | public SetMarketHandler(key: string, f: MarketHandler) { 44 | instance.marketHandler.set(key, f) 45 | } 46 | 47 | public SetTickerHandler(key: string, f: TickerHandler) { 48 | instance.tickerHandler.set(key, f) 49 | } 50 | 51 | public SetTradeHandler(key: string, f: TradeHandler) { 52 | instance.tradeHandler.set(key, f) 53 | } 54 | 55 | public SetDepthHandler(key: string, f: DepthHandler) { 56 | instance.depthHandler.set(key, f) 57 | } 58 | 59 | public SetKlineHandler(key: string, f: KlineHandler) { 60 | instance.klineHandler.set(key, f) 61 | } 62 | 63 | 64 | private onOpen() { 65 | console.log('WebSocket connection opened.'); 66 | } 67 | 68 | private onMessage(sub: Subscribe) { 69 | let arr = sub.topic.split("@") 70 | if (arr.length != 2) { 71 | return 72 | } 73 | if (arr[0] == "MARKET") { 74 | if (instance.marketHandler) { 75 | // MARKET@1440: type MarketHandler = (intvl: number, sub: Subscribe) => void; 76 | instance.marketHandler.forEach((value, key) => { 77 | value(Number(arr[1]), sub) 78 | }); 79 | } 80 | return; 81 | } 82 | let symbol = arr[0] 83 | if (arr[1] == "TRADE") { 84 | if (instance.tradeHandler) { 85 | // BTCUSDT@TRADE: type TradeHandler = (symbol: string, sub: Subscribe) => void; 86 | instance.tradeHandler.forEach((value, key) => { 87 | value(symbol, sub) 88 | }); 89 | } 90 | return; 91 | } 92 | arr = arr[1].split("_") 93 | if (arr.length != 2) { 94 | return 95 | } 96 | switch (arr[0]) { 97 | case "TICKER": 98 | if (instance.tickerHandler) { 99 | // BTCUSDT@TICKER_1440: type TickerHandler = (symbol: string, intvl: number, sub: Subscribe) => void; 100 | instance.tickerHandler.forEach((value, key) => { 101 | value(symbol, Number(arr[1]), sub) 102 | }); 103 | } 104 | break 105 | case "DEPTH": 106 | if (instance.depthHandler) { 107 | // BTCUSDT@DEPTH_10: type DepthHandler = (symbol: string, levels: number, sub: Subscribe) => void; 108 | instance.depthHandler.forEach((value, key) => { 109 | value(symbol, Number(arr[1]), sub) 110 | }); 111 | } 112 | break 113 | case "KLINE": 114 | if (instance.klineHandler) { 115 | // BTCUSDT@KLINE_1: type KlineHandler = (symbol: string, intvl: number, sub: Subscribe) => void; 116 | instance.klineHandler.forEach((value, key) => { 117 | value(symbol, Number(arr[1]), sub) 118 | }); 119 | } 120 | break 121 | } 122 | } 123 | 124 | private onError(error: Event) { 125 | console.error('WebSocket error:', error); 126 | } 127 | 128 | private onClose(event: CloseEvent) { 129 | console.log('WebSocket connection closed.', event.reason); 130 | } 131 | 132 | // 假设你的类有一些公共方法 133 | public connect(topics: string[]) { 134 | instance.client = new WebSocketClient(this.onOpen, this.onMessage, this.onError, this.onClose); 135 | instance.client.setTopics(topics) 136 | instance.client.connect(); 137 | } 138 | 139 | public updateSubscribe(topics: string[]) { 140 | instance.client.updateSubscribe(topics) 141 | } 142 | 143 | public updateUnSubscribe(oldTopic: string, newTopic: string) { 144 | instance.client.updateUnSubscribe(oldTopic, newTopic) 145 | } 146 | public reconnect() { 147 | instance.client.closeSocket(); 148 | instance.client.connect(); 149 | } 150 | public close() { 151 | instance.client.closeSocket(); 152 | } 153 | 154 | } 155 | 156 | export const instance = Singleton.getInstance(); -------------------------------------------------------------------------------- /src/views/trade/footer/components/currentOrder.vue: -------------------------------------------------------------------------------- 1 | 126 | 127 | 156 | 157 | -------------------------------------------------------------------------------- /src/views/trade/header/index.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 100 | 101 | -------------------------------------------------------------------------------- /src/pkg/xws/xws.ts: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie' 2 | 3 | export interface Message { 4 | method: string; 5 | data: any; 6 | } 7 | 8 | export interface Subscribe { 9 | topic: string; 10 | data: any; 11 | } 12 | 13 | type OpenEventHandler = () => void; 14 | type MessageEventHandler = (message: Subscribe) => void; 15 | type ErrorHandler = (error: Event) => void; 16 | type CloseEventHandler = (event: CloseEvent) => void; 17 | 18 | export class WebSocketClient { 19 | private socket: WebSocket | null; 20 | private openHandler: OpenEventHandler | null; 21 | private messageHandler: MessageEventHandler | null; 22 | private errorHandler: ErrorHandler | null; 23 | private closeHandler: CloseEventHandler | null; 24 | private topics: string[]; 25 | 26 | private connectCount: number = 0; 27 | private readonly maxConnectCount: number = 10; 28 | private readonly reconnectInterval: number = 3000; // 重连间隔时间(毫秒) 29 | private reconnecting: boolean = false; 30 | 31 | // 心跳检测相关方法 32 | private heartbeatInterval: number | null = null; 33 | private heartbeatTimeout: number | null = null; 34 | private heartbeatCount: number = 0; 35 | 36 | constructor( 37 | openCallback: OpenEventHandler = () => { 38 | }, 39 | messageCallback: MessageEventHandler = (_: Subscribe) => { 40 | }, 41 | errorCallback: ErrorHandler = (_) => { 42 | }, 43 | closeCallback: CloseEventHandler = (_) => { 44 | } 45 | ) { 46 | this.socket = null; 47 | this.openHandler = openCallback; 48 | this.messageHandler = messageCallback; 49 | this.errorHandler = errorCallback; 50 | this.closeHandler = closeCallback; 51 | } 52 | 53 | public setTopics(topics: string[]): void { 54 | this.topics = topics; 55 | } 56 | 57 | public updateSubscribe(topics: string[]) { 58 | const msg = { 59 | method: "UPDATE_SUBSCRIBE", 60 | data: [this.topics, topics], //0: old topics, 1: new topics 61 | } 62 | this.topics = topics 63 | this.sendMessage(msg) 64 | } 65 | 66 | public updateUnSubscribe(oldTopic: string, newTopic: string) { 67 | const msg = { 68 | method: "UPDATE_SUBSCRIBE", 69 | data: [[oldTopic], [newTopic]], 70 | } 71 | this.topics = this.topics.filter(item => item !== oldTopic); 72 | this.topics.push(newTopic) 73 | this.sendMessage(msg) 74 | } 75 | 76 | public connect(): void { 77 | this.connectCount++; 78 | const url = this.getWebSocketUrl(); 79 | try { 80 | this.socket = new WebSocket(url); 81 | this.setupSocketEventHandlers(); 82 | } catch (error) { 83 | console.error('Failed to create WebSocket:', error); 84 | this.scheduleReconnect(); 85 | } 86 | } 87 | 88 | // 发送消息方法 89 | public sendMessage(message: Message): void { 90 | if (this.socket && this.socket.readyState === WebSocket.OPEN) { 91 | this.socket.send(JSON.stringify(message)); 92 | } else { 93 | console.warn('Failed to send message. WebSocket not connected.'); 94 | } 95 | } 96 | 97 | private getWebSocketUrl(): string { 98 | const token = Cookies.get('token') as string 99 | const baseUrl = import.meta.env.VITE_WS_URL; 100 | return `${baseUrl}?topics=${this.topics.join(",")}&token=${token}`; 101 | } 102 | 103 | private setupSocketEventHandlers(): void { 104 | // 连接打开事件处理 105 | this.socket.addEventListener('open', (event: Event) => { 106 | this.connectCount = 0; 107 | this.reconnecting = false; 108 | this.openHandler(); 109 | // 开启心跳检测 110 | this.startHeartbeat(); 111 | }); 112 | 113 | // 消息接收事件处理 114 | this.socket.addEventListener('message', (event: MessageEvent) => { 115 | this.handleMessageEvent(event); 116 | }); 117 | 118 | // 错误事件处理 119 | this.socket.addEventListener('error', (event: Event) => { 120 | this.handleErrorEvent(event) 121 | }); 122 | 123 | // 连接关闭事件处理 124 | this.socket.addEventListener('close', (event: CloseEvent) => { 125 | this.handleCloseEvent(event) 126 | }); 127 | } 128 | 129 | private handleMessageEvent(event: MessageEvent) { 130 | // console.log("接收ws消息:", event.data); 131 | // 收到消息后重置心跳计数器 132 | this.resetHeartbeat(); 133 | const data = event.data; 134 | switch (data) { 135 | case 'PONG': 136 | return; 137 | case 'PING': 138 | this.sendPong(); 139 | return; 140 | } 141 | try { 142 | const messageData: Subscribe = JSON.parse(data); 143 | this.messageHandler(messageData); 144 | } catch (err) { 145 | console.log('Failed to parse message:', err); 146 | } 147 | } 148 | 149 | private handleErrorEvent(event: Event): void { 150 | this.errorHandler(event); 151 | this.scheduleReconnect(); 152 | } 153 | 154 | private handleCloseEvent(event: CloseEvent): void { 155 | this.stopHeartbeat(); 156 | if (!this.reconnecting) { 157 | this.closeHandler(event); 158 | } 159 | if (!event.wasClean) { 160 | // 非正常关闭时尝试重连 161 | console.log('Connection closed unexpectedly. Reconnecting...'); 162 | // 先关闭现有的连接 163 | this.closeSocket(); 164 | // 然后尝试重新连接 165 | this.reconnect(); 166 | } 167 | } 168 | 169 | private startHeartbeat(): void { 170 | this.heartbeatInterval = setInterval(() => { 171 | this.sendPing(); 172 | // 设置心跳超时时间 173 | this.heartbeatTimeout = setTimeout(() => { 174 | this.handleHeartbeatTimeout(); 175 | }, 5000); // 设置超时时间为 5 秒 176 | }, 30000); // 设置心跳间隔为 30 秒 177 | } 178 | 179 | private stopHeartbeat(): void { 180 | if (this.heartbeatInterval) { 181 | clearInterval(this.heartbeatInterval); 182 | } 183 | if (this.heartbeatTimeout) { 184 | clearTimeout(this.heartbeatTimeout); 185 | } 186 | } 187 | 188 | private resetHeartbeat(): void { 189 | this.heartbeatCount = 0; 190 | if (this.heartbeatTimeout) { 191 | clearTimeout(this.heartbeatTimeout); 192 | } 193 | } 194 | 195 | private sendPing(): void { 196 | if (this.socket && this.socket.readyState === WebSocket.OPEN) { 197 | try { 198 | this.socket.send("PING"); 199 | } catch (err) { 200 | console.error("Error sending PING:", err); 201 | this.handleErrorEvent(new Event('error')); 202 | } 203 | } else { 204 | console.warn("Cannot send PING: WebSocket not open."); 205 | this.stopHeartbeat(); 206 | } 207 | } 208 | 209 | private sendPong(): void { 210 | this.socket?.send("PONG"); 211 | } 212 | 213 | private handleHeartbeatTimeout(): void { 214 | this.heartbeatCount++; 215 | if (this.heartbeatCount >= 3) { 216 | // 连续 3 次心跳超时,关闭连接 217 | console.log('Heartbeat failed. Closing connection.'); 218 | this.closeSocket(); 219 | } else { 220 | // 重试发送心跳 221 | console.log('Heartbeat timeout. Retrying...'); 222 | this.resetHeartbeat(); 223 | } 224 | } 225 | 226 | public closeSocket(): void { 227 | if (this.socket) { 228 | if (this.socket.readyState === WebSocket.OPEN || 229 | this.socket.readyState === WebSocket.CONNECTING) { 230 | this.socket.close(); 231 | } 232 | this.socket = null; 233 | } 234 | this.stopHeartbeat(); 235 | } 236 | 237 | private reconnect(): void { 238 | if (!this.reconnecting) { 239 | this.reconnecting = true; 240 | this.closeSocket(); // 确保旧连接已关闭 241 | this.scheduleReconnect(); 242 | } 243 | } 244 | 245 | private scheduleReconnect(): void { 246 | if (this.connectCount <= this.maxConnectCount) { 247 | setTimeout(() => { 248 | console.log('Reconnecting...'); 249 | this.connect(); 250 | }, this.reconnectInterval); 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/views/trade/main/index.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 118 | 119 | -------------------------------------------------------------------------------- /src/views/trade/main/components/orderbook.vue: -------------------------------------------------------------------------------- 1 | 111 | 112 | 173 | 174 | -------------------------------------------------------------------------------- /src/assets/theme.scss: -------------------------------------------------------------------------------- 1 | // Binance风格主题变量 2 | :root { 3 | // 主色调 - 币安黄色 4 | --binance-primary: #f0b90b; 5 | --binance-primary-hover: #f8d33a; 6 | --binance-primary-active: #e0aa0b; 7 | 8 | // 背景色 - 深色背景 9 | --binance-bg-base: #0b0e11; // 更深的背景色 10 | --binance-bg-secondary: #161a1e; // 次级背景 11 | --binance-bg-tertiary: #222529; // 三级背景 12 | --binance-bg-card: #1e2026; // 卡片背景 13 | 14 | // 文本颜色 15 | --binance-text-primary: #eaecef; // 主要文本 16 | --binance-text-secondary: #848e9c; // 次要文本 17 | --binance-text-tertiary: #5e6673; // 三级文本 18 | 19 | // 边框颜色 20 | --binance-border-base: #2a2d35; // 基础边框 21 | --binance-border-light: #363a45; // 浅色边框 22 | 23 | // 功能色 24 | --binance-success: #03a66d; // 成功/买入绿 25 | --binance-success-light: rgba(3, 166, 109, 0.1); 26 | --binance-danger: #cf304a; // 危险/卖出红 27 | --binance-danger-light: rgba(207, 48, 74, 0.1); 28 | --binance-warning: #f0b90b; // 警告黄 29 | --binance-warning-light: rgba(240, 185, 11, 0.1); 30 | 31 | // 买卖盘颜色 32 | --binance-buy: #0ecb81; // 买入绿 33 | --binance-sell: #f6465d; // 卖出红 34 | 35 | // 阴影 36 | --binance-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 37 | 38 | // 圆角 39 | --binance-border-radius-sm: 4px; 40 | --binance-border-radius-md: 8px; 41 | --binance-border-radius-lg: 12px; 42 | 43 | // 间距 44 | --binance-spacing-xs: 4px; 45 | --binance-spacing-sm: 8px; 46 | --binance-spacing-md: 16px; 47 | --binance-spacing-lg: 24px; 48 | --binance-spacing-xl: 32px; 49 | 50 | // 表格样式 51 | --binance-table-header-bg: #121417; 52 | --binance-table-body-bg: #1e2026; 53 | --binance-table-hover-bg: #2b2f36; 54 | --binance-table-border: #2a2d35; 55 | 56 | // 图表样式 57 | --binance-chart-bg: #1e2026; 58 | --binance-chart-grid: #2a2d35; 59 | --binance-chart-text: #848e9c; 60 | } 61 | 62 | // 响应式断点 63 | $breakpoints: ( 64 | 'xs': 360px, 65 | 'sm': 768px, 66 | 'md': 1024px, 67 | 'lg': 1440px, 68 | 'xl': 1920px, 69 | ); 70 | 71 | @mixin respond-to($breakpoint) { 72 | $size: map-get($breakpoints, $breakpoint); 73 | 74 | @if $size { 75 | @media (min-width: $size) { 76 | @content; 77 | } 78 | } @else { 79 | @warn "Breakpoint `#{$breakpoint}` not found in $breakpoints."; 80 | } 81 | } 82 | 83 | // 重设默认样式 84 | * { 85 | margin: 0; 86 | padding: 0; 87 | box-sizing: border-box; 88 | } 89 | 90 | body { 91 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif; 92 | background-color: var(--binance-bg-base); 93 | color: var(--binance-text-primary); 94 | line-height: 1.5; 95 | -webkit-font-smoothing: antialiased; 96 | -moz-osx-font-smoothing: grayscale; 97 | } 98 | 99 | // Element Plus重写样式 100 | .el-button--primary { 101 | background-color: var(--binance-primary) !important; 102 | border-color: var(--binance-primary) !important; 103 | color: var(--binance-bg-base) !important; 104 | font-weight: 500 !important; 105 | 106 | &:hover { 107 | background-color: var(--binance-primary-hover) !important; 108 | border-color: var(--binance-primary-hover) !important; 109 | } 110 | 111 | &:active { 112 | background-color: var(--binance-primary-active) !important; 113 | border-color: var(--binance-primary-active) !important; 114 | } 115 | } 116 | 117 | .el-button { 118 | border-radius: var(--binance-border-radius-sm) !important; 119 | } 120 | 121 | .el-input__wrapper { 122 | background-color: var(--binance-bg-tertiary) !important; 123 | border-color: var(--binance-border-base) !important; 124 | border-radius: var(--binance-border-radius-sm) !important; 125 | 126 | &.is-focus { 127 | box-shadow: 0 0 0 1px var(--binance-primary) !important; 128 | } 129 | } 130 | 131 | .el-input__inner { 132 | color: var(--binance-text-primary) !important; 133 | } 134 | 135 | .el-tabs__item { 136 | color: var(--binance-text-secondary) !important; 137 | 138 | &.is-active { 139 | color: var(--binance-primary) !important; 140 | } 141 | } 142 | 143 | .el-tabs__active-bar { 144 | background-color: var(--binance-primary) !important; 145 | } 146 | 147 | // 表格组件样式统一 148 | .el-table { 149 | background-color: var(--binance-table-body-bg) !important; 150 | color: var(--binance-text-primary) !important; 151 | font-size: 13px !important; 152 | 153 | &::before { 154 | background-color: var(--binance-border-base) !important; 155 | height: 1px !important; 156 | } 157 | 158 | th.el-table__cell { 159 | background-color: var(--binance-table-header-bg) !important; 160 | color: var(--binance-text-secondary) !important; 161 | font-weight: 500 !important; 162 | border-bottom: 1px solid var(--binance-table-border) !important; 163 | padding: 8px 0 !important; 164 | font-size: 12px !important; 165 | } 166 | 167 | .el-table__row { 168 | background-color: var(--binance-table-body-bg) !important; 169 | 170 | td.el-table__cell { 171 | border-bottom: 1px solid var(--binance-table-border) !important; 172 | padding: 8px 0 !important; 173 | } 174 | 175 | &:hover > td.el-table__cell { 176 | background-color: var(--binance-table-hover-bg) !important; 177 | } 178 | 179 | &.current-row > td.el-table__cell { 180 | background-color: var(--binance-table-hover-bg) !important; 181 | } 182 | } 183 | 184 | .el-table__empty-block { 185 | background-color: var(--binance-table-body-bg) !important; 186 | 187 | .el-table__empty-text { 188 | color: var(--binance-text-tertiary) !important; 189 | } 190 | } 191 | 192 | .el-scrollbar__bar { 193 | background-color: var(--binance-bg-tertiary) !important; 194 | } 195 | } 196 | 197 | // El-Radio Button 样式统一 198 | .el-radio-button__inner { 199 | background-color: var(--binance-bg-tertiary) !important; 200 | border-color: var(--binance-border-base) !important; 201 | color: var(--binance-text-secondary) !important; 202 | font-size: 12px !important; 203 | height: 32px !important; 204 | padding: 0 12px !important; 205 | 206 | &:hover { 207 | color: var(--binance-primary) !important; 208 | } 209 | } 210 | 211 | .el-radio-button__original-radio:checked + .el-radio-button__inner { 212 | background-color: var(--binance-primary) !important; 213 | border-color: var(--binance-primary) !important; 214 | color: var(--binance-bg-base) !important; 215 | box-shadow: -1px 0 0 0 var(--binance-primary) !important; 216 | } 217 | 218 | .el-radio-group { 219 | --el-radio-button-checked-bg-color: var(--binance-primary); 220 | --el-radio-button-checked-text-color: var(--binance-bg-base); 221 | --el-radio-button-checked-border-color: var(--binance-primary); 222 | } 223 | 224 | // 自定义公共样式类 225 | .text-buy { 226 | color: var(--binance-buy) !important; 227 | } 228 | 229 | .text-sell { 230 | color: var(--binance-sell) !important; 231 | } 232 | 233 | .text-primary { 234 | color: var(--binance-primary) !important; 235 | } 236 | 237 | .bg-card { 238 | background-color: var(--binance-bg-card) !important; 239 | border-radius: var(--binance-border-radius-md); 240 | box-shadow: var(--binance-shadow); 241 | } 242 | 243 | // 响应式容器 244 | .container { 245 | width: 100%; 246 | margin: 0 auto; 247 | padding: 0 var(--binance-spacing-md); 248 | 249 | @include respond-to('sm') { 250 | max-width: 100%; 251 | padding: 0 var(--binance-spacing-sm); 252 | } 253 | 254 | @include respond-to('md') { 255 | max-width: 100%; 256 | padding: 0 var(--binance-spacing-md); 257 | } 258 | 259 | @include respond-to('lg') { 260 | max-width: 100%; 261 | padding: 0 var(--binance-spacing-md); 262 | } 263 | 264 | @include respond-to('xl') { 265 | max-width: 100%; 266 | padding: 0 var(--binance-spacing-lg); 267 | } 268 | } 269 | 270 | // 栅格系统 271 | .row { 272 | display: flex; 273 | flex-wrap: wrap; 274 | margin: 0 calc(-1 * var(--binance-spacing-md)); 275 | } 276 | 277 | .col { 278 | padding: 0 var(--binance-spacing-md); 279 | flex-grow: 1; 280 | } 281 | 282 | @for $i from 1 through 12 { 283 | .col-#{$i} { 284 | flex: 0 0 calc(#{$i} / 12 * 100%); 285 | max-width: calc(#{$i} / 12 * 100%); 286 | } 287 | 288 | @each $breakpoint, $size in $breakpoints { 289 | @include respond-to($breakpoint) { 290 | .col-#{$breakpoint}-#{$i} { 291 | flex: 0 0 calc(#{$i} / 12 * 100%); 292 | max-width: calc(#{$i} / 12 * 100%); 293 | } 294 | } 295 | } 296 | } 297 | 298 | // 修复一些常见布局问题 299 | .el-tabs--card > .el-tabs__header { 300 | border-bottom: 1px solid var(--binance-border-base) !important; 301 | margin: 0 !important; 302 | } 303 | 304 | .el-tabs--card > .el-tabs__header .el-tabs__nav { 305 | border: 1px solid var(--binance-border-base) !important; 306 | } 307 | 308 | .el-tabs--card > .el-tabs__header .el-tabs__item { 309 | border-left: 1px solid var(--binance-border-base) !important; 310 | border-bottom: 1px solid var(--binance-border-base) !important; 311 | height: 32px !important; 312 | line-height: 32px !important; 313 | } 314 | 315 | .el-tabs--card > .el-tabs__header .el-tabs__item.is-active { 316 | border-bottom-color: var(--binance-primary) !important; 317 | background-color: var(--binance-primary) !important; 318 | color: var(--binance-bg-base) !important; 319 | font-weight: 500 !important; 320 | } 321 | 322 | // 图表容器样式 323 | .chart-container { 324 | background-color: var(--binance-chart-bg) !important; 325 | border: 1px solid var(--binance-border-base) !important; 326 | border-radius: var(--binance-border-radius-md) !important; 327 | overflow: hidden !important; 328 | } 329 | 330 | // 时间选择器样式 331 | .time-selector { 332 | display: flex; 333 | flex-wrap: wrap; 334 | gap: var(--binance-spacing-xs); 335 | padding: var(--binance-spacing-xs) var(--binance-spacing-sm); 336 | background-color: var(--binance-bg-tertiary); 337 | border-radius: var(--binance-border-radius-sm); 338 | 339 | .time-item { 340 | padding: 4px 8px; 341 | border-radius: var(--binance-border-radius-sm); 342 | font-size: 12px; 343 | cursor: pointer; 344 | color: var(--binance-text-secondary); 345 | 346 | &:hover { 347 | background-color: rgba(240, 185, 11, 0.1); 348 | color: var(--binance-primary); 349 | } 350 | 351 | &.active { 352 | background-color: var(--binance-primary); 353 | color: var(--binance-bg-base); 354 | } 355 | } 356 | } 357 | 358 | // 全局滚动条样式 359 | ::-webkit-scrollbar { 360 | width: 6px; 361 | height: 6px; 362 | } 363 | 364 | ::-webkit-scrollbar-track { 365 | background: var(--binance-bg-tertiary); 366 | border-radius: 3px; 367 | } 368 | 369 | ::-webkit-scrollbar-thumb { 370 | background: var(--binance-border-light); 371 | border-radius: 3px; 372 | 373 | &:hover { 374 | background: var(--binance-text-tertiary); 375 | } 376 | } -------------------------------------------------------------------------------- /src/views/trade/main/components/market.vue: -------------------------------------------------------------------------------- 1 | 147 | 148 | 201 | 202 | 388 | -------------------------------------------------------------------------------- /src/views/trade/main/components/chart/chart.vue: -------------------------------------------------------------------------------- 1 | 304 | 305 | 328 | 329 | --------------------------------------------------------------------------------