feat: 更新 @riwa/api-types 依赖版本至 0.0.138;重构 WebSocket 逻辑以优化订阅和取消订阅功能;调整订单簿组件以支持新的数据结构

This commit is contained in:
2026-01-14 01:47:30 +07:00
parent c3a8908b8d
commit 513e444ec4
6 changed files with 419 additions and 66 deletions

View File

@@ -35,7 +35,7 @@
"@elysiajs/eden": "^1.4.5",
"@ionic/vue": "^8.7.11",
"@ionic/vue-router": "^8.7.11",
"@riwa/api-types": "http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz",
"@riwa/api-types": "http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz",
"@tailwindcss/vite": "^4.1.18",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.1.0",

12
pnpm-lock.yaml generated
View File

@@ -69,8 +69,8 @@ importers:
specifier: ^8.7.11
version: 8.7.11(@stencil/core@4.39.0)(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
'@riwa/api-types':
specifier: http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz
version: '@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))'
specifier: http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz
version: '@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))'
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))
@@ -2804,9 +2804,9 @@ packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz':
resolution: {tarball: http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz}
version: 0.0.135
'@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz':
resolution: {tarball: http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz}
version: 0.0.138
peerDependencies:
'@elysiajs/eden': ^1.4.5
@@ -12161,7 +12161,7 @@ snapshots:
'@remirror/core-constants@3.0.0': {}
'@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.135.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))':
'@riwa/eden@http://192.168.1.7:9528/api/riwa-eden-0.0.138.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))':
dependencies:
'@elysiajs/eden': 1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3))

View File

@@ -205,18 +205,14 @@ export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed {
console.log("[RWADatafeed]: subscribeBars", { symbol: symbolInfo.name, resolution, subscriberUID });
this.subscribers.set(subscriberUID, { callback: onTick, resolution, symbol: symbolInfo.name });
const wsConnection = tradeWebSocket.getSocket();
wsConnection.send({
action: "subscribe",
channels: [{
name: "bar",
symbol: symbolInfo.name,
resolution,
}],
});
tradeWebSocket.subscribeChannel([{
name: "bar",
symbol: symbolInfo.name,
resolution,
}]);
wsConnection.subscribe((message) => {
tradeWebSocket.subscribe((message) => {
const data = message.data as any;
if (data.type !== "bar")
return;
@@ -238,13 +234,10 @@ export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed {
unsubscribeBars(subscriberUID: string): void {
console.log("[RWADatafeed]: unsubscribeBars", subscriberUID);
const subscriber = this.subscribers.get(subscriberUID);
const wsConnection = tradeWebSocket.getSocket();
wsConnection.send({
action: "unsubscribe",
channels: [{
name: "bar",
symbol: subscriber?.symbol || "",
}],
});
tradeWebSocket.unsubscribeChannel([{
name: "bar",
symbol: subscriber?.symbol as string,
resolution: subscriber?.resolution as ResolutionString,
}]);
}
}

View File

@@ -1,30 +1,364 @@
import type { Treaty } from "@elysiajs/eden";
import type { MarketDataStreaming } from "@/api/types";
import { client } from "@/api";
interface SubscribeMessage {
action: "subscribe" | "unsubscribe" | "ping";
channels: Array<{
name: "depth" | "trades" | "ticker" | "bar";
symbol: string;
resolution?: string;
[key: string]: any;
}>;
}
type MessageData = any;
interface MessageHandler {
id: string;
handler: (data: MessageData) => void;
}
export class TradeWebSocket {
public socket: MarketDataStreaming | null = null;
private socket: MarketDataStreaming | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 3000;
private heartbeatInterval = 30000; // 30秒心跳
private isManualClose = false;
private connectionPromise: Promise<void> | null = null;
private connectionResolve: (() => void) | null = null;
private messageQueue: SubscribeMessage[] = [];
private messageHandlers: MessageHandler[] = [];
private subscriptions = new Set<string>();
constructor() {
if (!this.socket) {
this.socket = client.api.market_data.streaming.subscribe();
this.connect();
}
/**
* 连接 WebSocket
*/
private connect() {
if (this.socket) {
return this.connectionPromise;
}
this.socket.on("error", () => {
this.socket = null;
// 创建连接 Promise
this.connectionPromise = new Promise((resolve) => {
this.connectionResolve = resolve;
});
try {
this.socket = client.api.market_data.streaming.subscribe();
});
this.socket.on("open", () => {
console.log("TradeWebSocket connected");
this.socket.on("open", () => {
console.log("TradeWebSocket connected");
this.reconnectAttempts = 0;
this.startHeartbeat();
// 连接成功,通知等待的请求
if (this.connectionResolve) {
this.connectionResolve();
this.connectionResolve = null;
}
// 处理消息队列
this.processMessageQueue();
// 重新订阅之前的频道
this.resubscribe();
});
this.socket.on("message", (data) => {
this.handleMessage(data);
});
this.socket.on("error", (error: any) => {
console.error("❌ TradeWebSocket error:", error);
this.handleError();
});
this.socket.on("close", () => {
console.log("🔌 TradeWebSocket closed");
this.stopHeartbeat();
this.socket = null;
this.connectionPromise = null;
if (!this.isManualClose) {
this.reconnect();
}
});
}
catch (error) {
console.error("❌ Failed to create WebSocket:", error);
this.handleError();
}
return this.connectionPromise;
}
/**
* 处理接收到的消息
*/
private handleMessage(data: any) {
// 处理心跳响应
if (data.type === "pong") {
return;
}
// 分发消息给所有订阅者
this.messageHandlers.forEach((handler) => {
try {
handler.handler(data);
}
catch (error) {
console.error("❌ Message handler error:", error);
}
});
}
public getSocket(): MarketDataStreaming {
return this.socket!;
/**
* 错误处理
*/
private handleError() {
this.stopHeartbeat();
this.socket = null;
this.connectionPromise = null;
if (!this.isManualClose) {
this.reconnect();
}
}
/**
* 重连逻辑
*/
private reconnect() {
if (this.reconnectTimer || this.reconnectAttempts >= this.maxReconnectAttempts) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error("❌ Max reconnect attempts reached");
}
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.min(this.reconnectAttempts, 5);
console.log(`🔄 Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
/**
* 启动心跳
*/
private startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.socket && this.isConnected()) {
try {
this.socket.send({ action: "ping" });
}
catch (error) {
console.error("❌ Heartbeat error:", error);
}
}
}, this.heartbeatInterval);
}
/**
* 停止心跳
*/
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* 处理消息队列
*/
private processMessageQueue() {
if (!this.isConnected()) {
return;
}
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (message) {
this.sendImmediate(message);
}
}
}
/**
* 重新订阅所有频道
*/
private resubscribe() {
if (this.subscriptions.size === 0) {
return;
}
const channels = Array.from(this.subscriptions).map((sub) => {
const [name, symbol] = sub.split(":") as any[];
return { name, symbol };
});
if (channels.length > 0) {
this.send({
action: "subscribe",
channels,
});
}
}
/**
* 检查是否已连接
*/
public isConnected(): boolean {
return this.socket !== null && this.connectionResolve === null;
}
/**
* 等待连接建立
*/
public async waitForConnection(): Promise<void> {
if (this.isConnected()) {
return Promise.resolve();
}
if (!this.connectionPromise) {
this.connect();
}
return this.connectionPromise!;
}
/**
* 发送消息(立即发送)
*/
private sendImmediate(message: SubscribeMessage) {
if (!this.socket) {
console.warn("⚠️ Socket not connected, message queued");
return;
}
try {
this.socket.send(message);
// 记录订阅状态
if (message.action === "subscribe") {
message.channels.forEach((channel) => {
const key = channel.symbol ? `${channel.name}:${channel.symbol}` : channel.name;
this.subscriptions.add(key);
});
}
else if (message.action === "unsubscribe") {
message.channels.forEach((channel) => {
const key = channel.symbol ? `${channel.name}:${channel.symbol}` : channel.name;
this.subscriptions.delete(key);
});
}
}
catch (error) {
console.error("❌ Failed to send message:", error);
throw error;
}
}
/**
* 发送消息(带队列)
*/
public async send(message: SubscribeMessage) {
// 等待连接建立
await this.waitForConnection();
// 如果已连接,立即发送
if (this.isConnected()) {
this.sendImmediate(message);
}
else {
// 否则加入队列
this.messageQueue.push(message);
}
}
/**
* 订阅消息
*/
public subscribe(handler: (data: MessageData) => void): string {
const id = `handler_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
this.messageHandlers.push({ id, handler });
return id;
}
/**
* 取消订阅消息
*/
public unsubscribe(id: string) {
const index = this.messageHandlers.findIndex(h => h.id === id);
if (index !== -1) {
this.messageHandlers.splice(index, 1);
}
}
/**
* 订阅频道
*/
public async subscribeChannel(channels: SubscribeMessage["channels"]) {
await this.send({
action: "subscribe",
channels,
});
}
/**
* 取消订阅频道
*/
public async unsubscribeChannel(channels: SubscribeMessage["channels"]) {
await this.send({
action: "unsubscribe",
channels,
});
}
/**
* 关闭连接
*/
public closeSocket() {
this.isManualClose = true;
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.connectionPromise = null;
this.messageQueue = [];
this.subscriptions.clear();
}
/**
* 重置并重新连接
*/
public reset() {
this.isManualClose = false;
this.reconnectAttempts = 0;
this.closeSocket();
this.connect();
}
}

View File

@@ -1,16 +1,25 @@
<script setup lang="ts">
import type { Treaty } from "@elysiajs/eden";
import { client, safeClient } from "@/api";
import { tradeWebSocket } from "@/tradingview/websocket";
type Item = Treaty.Data<typeof client.api.trading_pairs.orderbook.get>;
interface Item {
asks: Array<{
price: string;
size: string;
}>;
bids: Array<{
price: string;
size: string;
}>;
}
const props = defineProps<{
symbol: string;
}>();
const socket = tradeWebSocket.getSocket();
const { data } = await safeClient(client.api.trading_pairs.orderbook.get({ query: { symbol: props.symbol, depth: 5 } }));
const data = ref<Item>({
asks: [],
bids: [],
});
const latestPrice = computed(() => {
if (!data.value || !data.value.asks?.length || !data.value.bids?.length) {
@@ -22,41 +31,44 @@ const latestPrice = computed(() => {
return price;
});
function getTotal(item: Item["asks" | "bids"][number]) {
const price = Number(item.price);
const quantity = Number(item.quantity);
return (price * quantity).toFixed(2);
}
function getDepthPercent(items?: Item["asks" | "bids"], index?: number) {
if (!items || index === undefined)
return 0;
const totalValues = items.map(item => Number(item.price) * Number(item.quantity));
const totalValues = items.map(item => Number(item.price) * Number(item.size));
const maxTotal = Math.max(...totalValues);
const itemTotal = totalValues[index];
return (itemTotal / maxTotal) * 100;
}
function subscribe() {
socket.send({
action: "subscribe",
channels: [{
name: "depth",
symbol: props.symbol,
}],
});
tradeWebSocket.subscribeChannel([{
name: "depth",
symbol: props.symbol,
}]);
}
function unsubscribe() {
socket.send({
action: "unsubscribe",
channels: [{
useTimeoutFn(() => {
console.log("unsubscribe order book: ", props.symbol);
tradeWebSocket.unsubscribeChannel([{
name: "depth",
symbol: props.symbol,
}],
});
}]);
}, 3000);
}
// 监听订单簿更新
socket.subscribe((message) => {
const data = message.data;
tradeWebSocket.subscribe((message) => {
if ("bids" in message.data) {
if (!data.value?.bids.length) {
data.value!.bids = [];
}
data.value!.bids.splice(0, data.value!.bids.length, ...message.data.bids);
}
if ("asks" in message.data) {
if (!data.value?.asks.length) {
data.value!.asks = [];
}
data.value!.asks.splice(0, data.value!.asks.length, ...message.data.asks);
}
});
watch(() => props.symbol, (newSymbol, oldSymbol) => {
@@ -90,8 +102,7 @@ onUnmounted(() => {
/>
<div class="order-content">
<span class="price text-danger-500">{{ Number(ask.price).toString() }}</span>
<span class="amount">{{ Number(ask.quantity).toString() }}</span>
<span class="total">{{ getTotal(ask) }}</span>
<span class="amount">{{ ask.size }}</span>
</div>
</div>
</div>
@@ -121,8 +132,7 @@ onUnmounted(() => {
/>
<div class="order-content">
<span class="price text-success-500">{{ Number(bid.price).toString() }}</span>
<span class="amount">{{ Number(bid.quantity).toString() }}</span>
<span class="total">{{ getTotal(bid) }}</span>
<span class="amount">{{ Number(bid.size).toString() }}</span>
</div>
</div>
</div>

View File

@@ -41,16 +41,32 @@ const [form, reset] = useResetRef<SpotOrderBody & { amount: string }>({
amount: "",
});
watch(() => [form.value.symbol, form.value.orderType, form.value.side], async ([symbol, type, side]) => {
const { data } = await safeClient(client.api.market_data.pairs.price_preview.get({
query: { symbol },
}));
if (type === TradeWayValueEnum.LIMIT) {
form.value.price = Number((side === TradeTypeEnum.BUY ? data.value?.ask : data.value?.bid) || data.value?.mid || "").toString();
}
else {
form.value.price = "";
}
}, { immediate: true });
async function openTradePairs() {
const modal = await modalController.create({
component: TradePairsModal,
componentProps: {
onSelect: (val) => {
symbol.value = val;
form.value.symbol = val;
},
},
breakpoints: [0, 0.95],
initialBreakpoint: 0.95,
handle: true,
});
await modal.present();
const { data: result } = await modal.onWillDismiss<string>();
result && (symbol.value = result);
}
// 价格变化时,根据数量计算金额