feat: 重构 RWADatafeed 以使用 TradeWebSocket,优化订阅和取消订阅逻辑;在转账组件中添加币种选择和可用余额同步功能
This commit is contained in:
@@ -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 || "",
|
||||||
|
}],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/tradingview/websocket.ts
Normal file
24
src/tradingview/websocket.ts
Normal 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();
|
||||||
@@ -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>
|
||||||
|
|
||||||
<!-- 提交按钮 -->
|
<!-- 提交按钮 -->
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user