feat: 重构 RWADatafeed 以使用 TradeWebSocket,优化订阅和取消订阅逻辑;在转账组件中添加币种选择和可用余额同步功能

This commit is contained in:
2026-01-13 18:56:47 +07:00
parent 62f1c88d2c
commit ee755dc36e
4 changed files with 125 additions and 59 deletions

View File

@@ -13,15 +13,14 @@ import type {
SubscribeBarsCallback, SubscribeBarsCallback,
} from "#/datafeed-api"; } from "#/datafeed-api";
import type { MarketDataStreaming } from "@/api/types"; import type { MarketDataStreaming } from "@/api/types";
import { client } from "@/api"; import { tradeWebSocket } from "./websocket";
/** /**
* 自定义 TradingView Datafeed 实现 * 自定义 TradingView Datafeed 实现
* 用于从后端 API 获取 K 线数据 * 用于从后端 API 获取 K 线数据
*/ */
export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed { export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed {
private subscribers: Map<string, { callback: SubscribeBarsCallback; resolution: ResolutionString }> = new Map(); private subscribers: Map<string, { callback: SubscribeBarsCallback; resolution: ResolutionString; symbol: string }> = new Map();
private wsConnections: Map<string, MarketDataStreaming> = new Map();
constructor(apiUrl: string) { constructor(apiUrl: string) {
super(apiUrl); super(apiUrl);
@@ -203,28 +202,30 @@ export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed {
subscriberUID: string, subscriberUID: string,
onResetCacheNeededCallback: () => void, onResetCacheNeededCallback: () => void,
): void { ): void {
console.log("[RWADatafeed]: subscribeBars", { 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, symbol: symbolInfo.name,
resolution, }],
subscriberUID,
});
this.subscribers.set(subscriberUID, { callback: onTick, resolution });
const ws = client.api.market_data.streaming.subscribe();
this.wsConnections.set(subscriberUID, ws);
ws.on("open", () => {
ws.send({ type: "subscribe", subscriberUID, symbol: symbolInfo.name, resolution });
}); });
ws.subscribe((message) => { wsConnection.subscribe((message) => {
if (message.data.type !== "bar") const data = message.data as any;
if (data.type !== "bar")
return; return;
const bar: Bar = { const bar: Bar = {
time: message.data.bar.time, time: data.bar.time,
open: message.data.bar.open, open: data.bar.open,
high: message.data.bar.high, high: data.bar.high,
low: message.data.bar.low, low: data.bar.low,
close: message.data.bar.close, close: data.bar.close,
volume: message.data.bar.volume, volume: data.bar.volume,
}; };
onTick(bar); onTick(bar);
}); });
@@ -235,10 +236,14 @@ export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed {
*/ */
unsubscribeBars(subscriberUID: string): void { unsubscribeBars(subscriberUID: string): void {
console.log("[RWADatafeed]: unsubscribeBars", subscriberUID); console.log("[RWADatafeed]: unsubscribeBars", subscriberUID);
const ws = this.wsConnections.get(subscriberUID); const subscriber = this.subscribers.get(subscriberUID);
if (ws) { const wsConnection = tradeWebSocket.getSocket();
ws.close(); wsConnection.send({
this.wsConnections.delete(subscriberUID); action: "unsubscribe",
} channels: [{
name: "bar",
symbol: subscriber?.symbol || "",
}],
});
} }
} }

View File

@@ -0,0 +1,24 @@
import type { MarketDataStreaming } from "@/api/types";
import { client } from "@/api";
export class TradeWebSocket {
public socket: MarketDataStreaming | null = null;
constructor() {
if (!this.socket) {
this.socket = client.api.market_data.streaming.subscribe();
}
}
public getSocket(): MarketDataStreaming {
return this.socket!;
}
public closeSocket() {
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
}
export const tradeWebSocket = new TradeWebSocket();

View File

@@ -1,13 +1,15 @@
<script lang='ts' setup> <script lang='ts' setup>
import type { GenericObject } from "vee-validate"; import type { GenericObject } from "vee-validate";
import type { FormInstance } from "@/utils"; import type { FormInstance } from "@/utils";
import { loadingController, toastController } from "@ionic/vue"; import { loadingController, modalController, toastController } from "@ionic/vue";
import { toTypedSchema } from "@vee-validate/zod";
import { swapVerticalOutline } from "ionicons/icons"; import { swapVerticalOutline } from "ionicons/icons";
import { ErrorMessage, Field, Form } from "vee-validate"; import { ErrorMessage, Field, Form } from "vee-validate";
import { z } from "zod"; import { z } from "zod";
import { client, safeClient } from "@/api"; import { client, safeClient } from "@/api";
import { AssetCodeEnum } from "@/api/enum"; import { AssetCodeEnum } from "@/api/enum";
import { getCryptoIcon } from "@/config/crypto"; import { getCryptoIcon } from "@/config/crypto";
import SelectCurrency from "../withdraw/components/select-currency.vue";
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
@@ -31,18 +33,41 @@ const initialValues: TransferForm = {
fromAccount: "funding", fromAccount: "funding",
toAccount: "trading", toAccount: "trading",
}; };
const availableBalance = ref("0");
// 验证规则 // 验证规则
const schema = computed(() => z.object({ const schema = computed(() => toTypedSchema(z.object({
assetCode: z.string({ message: t("transfer.assetCodeRequired") }).min(1, t("transfer.assetCodeRequired")), assetCode: z.string({ message: t("transfer.assetCodeRequired") }).min(1, t("transfer.assetCodeRequired")),
amount: z amount: z
.string({ message: t("transfer.amountRequired") }) .union([z.string(), z.number()])
.min(1, t("transfer.amountRequired")) .refine(value => !Number.isNaN(Number(value)) && Number(value) > 0, { message: t("transfer.amountMinError") })
.refine(value => Number(value) > 0, t("transfer.amountMinError")) .refine(value => Number(value) <= Number(availableBalance.value || 0), { message: t("transfer.amountMaxError", { amount: availableBalance.value }) }),
.refine(value => Number(value) <= Number(USDTBalance.value?.available), t("transfer.amountMaxError", { amount: USDTBalance.value?.available })),
fromAccount: z.string({ message: t("transfer.fromAccountRequired") }).min(1, t("transfer.fromAccountRequired")), fromAccount: z.string({ message: t("transfer.fromAccountRequired") }).min(1, t("transfer.fromAccountRequired")),
toAccount: z.string({ message: t("transfer.toAccountRequired") }).min(1, t("transfer.toAccountRequired")), toAccount: z.string({ message: t("transfer.toAccountRequired") }).min(1, t("transfer.toAccountRequired")),
})); })));
// 监听表单字段变化,自动更新可用余额
watch(() => formRef.value?.getValues(), (values) => {
if (values?.assetCode && values?.fromAccount) {
syncAvailableBalance();
}
}, { deep: true });
async function openSelectCurrency() {
const modal = await modalController.create({
component: SelectCurrency,
componentProps: {
onSelect: (code: string) => {
formRef.value?.setFieldValue("assetCode", code);
syncAvailableBalance();
},
},
breakpoints: [0, 0.8],
initialBreakpoint: 0.8,
handle: true,
});
await modal.present();
}
// 交换账户 // 交换账户
function swapAccounts() { function swapAccounts() {
@@ -55,6 +80,9 @@ function swapAccounts() {
form.setFieldValue("fromAccount", currentTo); form.setFieldValue("fromAccount", currentTo);
form.setFieldValue("toAccount", currentFrom); form.setFieldValue("toAccount", currentFrom);
// 交换账户后重新获取余额
syncAvailableBalance();
} }
// 设置全部金额 // 设置全部金额
@@ -63,9 +91,26 @@ function setMaxAmount() {
if (!form) if (!form)
return; return;
form.setFieldValue("amount", USDTBalance.value?.available || "0"); form.setFieldValue("amount", availableBalance.value || "0");
} }
async function syncAvailableBalance() {
const values = formRef.value?.getValues();
if (!values || !values.assetCode || !values.fromAccount)
return;
const { data } = await safeClient(client.api.wallet.balance({ assetCode: values.assetCode }).get({
query: { accountType: values.fromAccount },
}));
if (data.value) {
availableBalance.value = data.value.available;
}
}
// 初始化时获取余额
onMounted(() => {
syncAvailableBalance();
});
// 提交划转 // 提交划转
async function onSubmit(values: GenericObject) { async function onSubmit(values: GenericObject) {
const loading = await loadingController.create({ const loading = await loadingController.create({
@@ -128,27 +173,20 @@ function getAccountTypeName(type: AccountType) {
<!-- 币种选择 --> <!-- 币种选择 -->
<div> <div>
<Field name="assetCode"> <Field name="assetCode">
<template #default="{ field, value }"> <template #default="{ value }">
<ion-radio-group v-bind="field" :model-value="value"> <ion-label class="block text-sm font-medium mb-2">
<ion-label class="block text-sm font-medium mb-3"> 选择币种
{{ t("transfer.chooseCurrency") }}
</ion-label> </ion-label>
<div class="flex gap-3"> <div
<ion-item class="flex items-center justify-between bg-text-900 rounded-2xl p-4 cursor-pointer"
v-for="item in AssetCodeEnum" @click="openSelectCurrency"
:key="item"
class="flex-1"
lines="none"
> >
<ion-radio :value="item"> <div class="flex items-center gap-3">
<div class="flex items-center gap-2"> <component :is="getCryptoIcon(value)" class="w-8 h-8" />
<component :is="getCryptoIcon(item)" class="w-6 h-6" /> <span class="text-base font-medium">{{ value }}</span>
{{ item }}
</div> </div>
</ion-radio> <ion-icon name="chevron-forward-outline" class="text-text-400" />
</ion-item>
</div> </div>
</ion-radio-group>
</template> </template>
</Field> </Field>
<ErrorMessage name="assetCode" class="text-red-500 text-xs mt-1" /> <ErrorMessage name="assetCode" class="text-red-500 text-xs mt-1" />
@@ -205,10 +243,10 @@ function getAccountTypeName(type: AccountType) {
<ion-button <ion-button
fill="clear" fill="clear"
size="small" size="small"
class="absolute right-0 top-8 text-sm font-semibold" class="absolute right-0 top-10.5 text-sm font-semibold z-10"
@click="setMaxAmount" @click="setMaxAmount"
> >
{{ t("transfer.all") }} 全部
</ion-button> </ion-button>
</div> </div>
</template> </template>
@@ -219,7 +257,7 @@ function getAccountTypeName(type: AccountType) {
<!-- 可用余额 --> <!-- 可用余额 -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm text-(--ion-color-medium)">{{ t("transfer.available") }}</span> <span class="text-sm text-(--ion-color-medium)">{{ t("transfer.available") }}</span>
<span class="text-sm font-medium">{{ Number(USDTBalance?.available).toFixed(2) }}</span> <span class="text-sm font-medium">{{ Number(availableBalance).toFixed(2) }}</span>
</div> </div>
<!-- 提交按钮 --> <!-- 提交按钮 -->

View File

@@ -2,7 +2,6 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import process from "node:process"; import process from "node:process";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import basicSsl from "@vitejs/plugin-basic-ssl";
import legacy from "@vitejs/plugin-legacy"; import legacy from "@vitejs/plugin-legacy";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import jsx from "@vitejs/plugin-vue-jsx"; import jsx from "@vitejs/plugin-vue-jsx";
@@ -79,7 +78,7 @@ export default defineConfig({
skipWaiting: true, skipWaiting: true,
}, },
}), }),
basicSsl(), // basicSsl(),
generateVersion({ generateVersion({
version: appVersion, version: appVersion,
}), }),