feat: 添加交易方式配置,更新相关组件以支持限价和市价委托

This commit is contained in:
2026-01-11 14:34:00 +07:00
parent dcfb7d11bf
commit 6f8a8de9be
6 changed files with 147 additions and 304 deletions

View File

@@ -94,6 +94,8 @@ export type EaringsDetailData = Treaty.Data<typeof client.api.earnings.details.p
export type EaringsDetailBody = TreatyBody<typeof client.api.earnings.details.post>;
export type SpotOrderBody = TreatyBody<typeof client.api.spot_order.create.post>;
/**
* 应用版本信息
*/

View File

@@ -0,0 +1,9 @@
<script lang='ts' setup>
</script>
<template>
Hello world
</template>
<style lang='css' scoped></style>

View File

@@ -1,292 +0,0 @@
<script setup lang="ts">
type OrderType = "limit" | "market";
type TradeAction = "buy" | "sell";
interface TradeFormData {
orderType: OrderType;
price: string;
amount: string;
total: string;
}
const props = defineProps<{
action: TradeAction;
}>();
const formData = reactive<TradeFormData>({
orderType: "limit",
price: "",
amount: "",
total: "",
});
// TODO: 后续从API获取用户余额
// const { data: balance } = await client.api.wallet.balances.get({ query: { accountType: 'trading' } })
const availableBalance = ref({
base: "0.00000000", // BTC
quote: "10000.00", // USDT
});
const isBuy = computed(() => props.action === "buy");
const buttonColor = computed(() => isBuy.value ? "success" : "danger");
const buttonText = computed(() => isBuy.value ? "买入 BTC" : "卖出 BTC");
// 计算总价
watch(() => [formData.price, formData.amount], () => {
if (formData.price && formData.amount) {
const price = Number.parseFloat(formData.price);
const amount = Number.parseFloat(formData.amount);
if (!Number.isNaN(price) && !Number.isNaN(amount)) {
formData.total = (price * amount).toFixed(2);
}
}
});
// 根据总价计算数量
watch(() => formData.total, (newTotal) => {
if (newTotal && formData.price) {
const total = Number.parseFloat(newTotal);
const price = Number.parseFloat(formData.price);
if (!Number.isNaN(total) && !Number.isNaN(price) && price > 0) {
formData.amount = (total / price).toFixed(8);
}
}
});
function setPercentage(percent: number) {
if (isBuy.value) {
// 买入根据USDT余额计算
const balance = Number.parseFloat(availableBalance.value.quote);
const total = balance * percent;
formData.total = total.toFixed(2);
}
else {
// 卖出根据BTC余额计算
const balance = Number.parseFloat(availableBalance.value.base);
formData.amount = (balance * percent).toFixed(8);
}
}
async function handleSubmit() {
// TODO: 后续调用交易API
// await client.api.trading.order.post({
// symbol: 'BTC_USDT',
// side: props.action,
// type: formData.orderType,
// price: formData.orderType === 'limit' ? formData.price : undefined,
// quantity: formData.amount
// })
console.log("提交订单", {
action: props.action,
...formData,
});
}
</script>
<template>
<div class="trade-form">
<!-- 订单类型切换 -->
<div class="order-type-tabs">
<button
:class="{ active: formData.orderType === 'limit' }"
@click="formData.orderType = 'limit'"
>
限价
</button>
<button
:class="{ active: formData.orderType === 'market' }"
@click="formData.orderType = 'market'"
>
市价
</button>
</div>
<!-- 可用余额 -->
<div class="balance-info">
<span class="label">可用</span>
<span class="value">
{{ isBuy ? availableBalance.quote : availableBalance.base }}
{{ isBuy ? 'USDT' : 'BTC' }}
</span>
</div>
<!-- 价格输入 -->
<div v-if="formData.orderType === 'limit'" class="input-group">
<label>价格</label>
<div class="input-wrapper">
<input
v-model="formData.price"
type="number"
placeholder="请输入价格"
>
<span class="unit">USDT</span>
</div>
</div>
<!-- 数量输入 -->
<div class="input-group">
<label>数量</label>
<div class="input-wrapper">
<input
v-model="formData.amount"
type="number"
placeholder="请输入数量"
>
<span class="unit">BTC</span>
</div>
</div>
<!-- 百分比选择 -->
<div class="percentage-buttons">
<button @click="setPercentage(0.25)">
25%
</button>
<button @click="setPercentage(0.5)">
50%
</button>
<button @click="setPercentage(0.75)">
75%
</button>
<button @click="setPercentage(1)">
100%
</button>
</div>
<!-- 总额 -->
<div class="input-group">
<label>总额</label>
<div class="input-wrapper">
<input
v-model="formData.total"
type="number"
placeholder="请输入总额"
>
<span class="unit">USDT</span>
</div>
</div>
<!-- 提交按钮 -->
<ion-button
expand="block"
:color="buttonColor"
class="submit-button"
@click="handleSubmit"
>
{{ buttonText }}
</ion-button>
</div>
</template>
<style scoped>
.trade-form {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.order-type-tabs {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.order-type-tabs button {
flex: 1;
padding: 8px 0;
background: transparent;
border: none;
color: var(--ion-text-color-step-400);
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.order-type-tabs button.active {
color: var(--ion-color-primary);
border-bottom-color: var(--ion-color-primary);
}
.balance-info {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--ion-text-color-step-400);
margin-bottom: 4px;
}
.balance-info .value {
color: var(--ion-text-color);
font-weight: 500;
}
.input-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.input-group label {
font-size: 12px;
color: var(--ion-text-color-step-400);
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
background: var(--ion-color-light);
border-radius: 8px;
padding: 12px;
}
.input-wrapper input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 14px;
color: var(--ion-text-color);
}
.input-wrapper input::placeholder {
color: var(--ion-text-color-step-600);
}
.input-wrapper .unit {
font-size: 12px;
color: var(--ion-text-color-step-400);
margin-left: 8px;
}
.percentage-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.percentage-buttons button {
padding: 6px 0;
background: var(--ion-color-light);
border: none;
border-radius: 6px;
font-size: 12px;
color: var(--ion-text-color);
cursor: pointer;
transition: all 0.2s;
}
.percentage-buttons button:active {
transform: scale(0.95);
background: var(--ion-color-light-shade);
}
.submit-button {
margin-top: 8px;
--border-radius: 8px;
font-weight: 600;
}
</style>

View File

@@ -1,19 +1,71 @@
<script lang='ts' setup>
import type { PropType } from "vue";
import type { TradeWayConfig } from "../config";
import type { SpotOrderBody } from "@/api/types";
import { modalController } from "@ionic/vue";
import { caretDownOutline } from "ionicons/icons";
import { tradeWayConfig } from "../config";
const model = defineModel({ type: Object as PropType<SpotOrderBody>, required: true });
function onSelectTradeWay(item: TradeWayConfig) {
model.value.orderType = item.value;
modalController.dismiss();
}
</script>
<template>
<div class="bg-faint flex items-center justify-between px-4 py-2 rounded-md">
<div id="open-modal" class="bg-faint flex items-center justify-between px-4 py-2 rounded-md">
<div class="text-xs font-medium text-text-300">
市场
</div>
<ion-icon :icon="caretDownOutline" />
</div>
<div class="bg-faint flex items-center justify-between px-4 py-2 rounded-md">
<div class="text-xs font-medium text-text-400">
市场价格
<ion-modal trigger="open-modal" :breakpoints="[0, 0.8]" :initial-breakpoint="0.8" handle>
<div class="ion-padding">
<div class="text-xs pb-3 text-text-300">
基础委托
</div>
<div class="flex flex-col gap-4">
<div v-for="(item, index) in tradeWayConfig" :key="index" class="flex items-center" @click="onSelectTradeWay(item)">
<div class="flex items-center flex-1">
<Icon :icon="item.icon" class="text-2xl mr-4" />
<div class="flex flex-col gap-1">
<div class="text-base font-semibold">
{{ item.name }}
</div>
<div class="text-xs text-text-500">
{{ item.description }}
</div>
</div>
</div>
<div v-show="model.orderType === item.value" class="text-primary">
<Icon icon="ic:sharp-check-circle" class="text-2xl" />
</div>
</div>
<div class="flex items-center">
<Icon icon="ic:twotone-more-horiz" class="text-2xl mr-4" />
<div class="flex flex-col gap-1">
<div class="text-sm text-text-500">
更多委托类型敬请期待
</div>
</div>
</div>
</div>
</div>
</div>
</ion-modal>
</template>
<style lang='css' scoped></style>
<style lang='css' scoped>
ion-input.count {
--background: var(--ion-color-faint);
--padding-start: 16px;
--padding-end: 16px;
--padding-top: 4px;
--padding-bottom: 4px;
--border-radius: 6px;
font-size: 12px;
min-height: 40px;
}
</style>

48
src/views/trade/config.ts Normal file
View File

@@ -0,0 +1,48 @@
import * as yup from "yup";
export enum TradeWayValueEnum {
LIMIT = "limit",
MARKET = "market",
}
export type TradeWayValue = `${TradeWayValueEnum}`;
export interface TradeWayConfig {
name: string;
value: TradeWayValue;
description: string;
icon: string;
}
export const tradeWayConfig: TradeWayConfig[] = [
{
name: "限价委托",
value: "limit",
description: "以指定价格买入或卖出",
icon: "hugeicons:trade-up",
},
];
export const confirmOrderSchema = yup.object({
price: yup.number().when("way", {
is: TradeWayValueEnum.LIMIT !== undefined,
then: yup
.number()
.typeError("请输入有效的价格")
.required("价格为必填项")
.moreThan(0, "价格必须大于0"),
otherwise: yup.number().notRequired(),
}),
amount: yup
.number()
.typeError("请输入有效的数量")
.required("数量为必填项")
.moreThan(0, "数量必须大于0"),
way: yup
.mixed<TradeWayValue>()
.oneOf(
Object.values(TradeWayValueEnum),
"请选择有效的交易方式",
)
.required("交易方式为必填项"),
});

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ChartingLibraryWidgetOptions } from "#/charting_library";
import type { SpotOrderBody } from "@/api/types";
import type { TradingViewInst } from "@/tradingview/index";
import { modalController } from "@ionic/vue";
import { useRouteQuery } from "@vueuse/router";
@@ -12,6 +13,7 @@ import OrdersPanel from "./components/orders-panel.vue";
import TradePairsModal from "./components/trade-pairs-modal.vue";
import TradeSwitch from "./components/trade-switch.vue";
import TradeWay from "./components/trade-way.vue";
import { confirmOrderSchema, TradeWayValueEnum } from "./config";
const mode = useRouteQuery<TradeTypeEnum>("mode", TradeTypeEnum.BUY);
const symbol = useRouteQuery<string>("symbol", "BTCUSD");
@@ -22,6 +24,14 @@ const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = {
],
};
const tradingViewInst = useTemplateRef<TradingViewInst>("tradingViewInst");
const [form] = useResetRef<SpotOrderBody>({
orderType: TradeWayValueEnum.LIMIT,
quantity: "",
side: mode.value,
symbol: symbol.value,
memo: "",
price: "",
});
async function openTradePairs() {
const modal = await modalController.create({
@@ -39,6 +49,14 @@ async function openTradePairs() {
symbol.value = result;
}
}
function handleSubmit() {
confirmOrderSchema.validate(form.value).then(() => {
console.log("submit successfully");
}).catch((err) => {
console.log("submit failed:", err);
});
}
</script>
<template>
@@ -69,14 +87,20 @@ async function openTradePairs() {
<div class="grid grid-cols-5 px-4">
<div class="col-span-3 space-y-2">
<TradeSwitch v-model:active="mode" />
<TradeWay />
<ion-input label="金额" class="count" inputmode="decimal" type="number" placeholder="请输入交易金额">
<TradeSwitch v-model:active="mode" @update:active="val => form.side = val" />
<TradeWay v-model="form" />
<template v-if="form.orderType === 'limit'">
<ion-input v-model="form.price" label="价格" class="count" inputmode="decimal" type="number" placeholder="请输入价格(USDT)" />
</template>
<ion-input v-model="form.quantity" label="数量" class="count" inputmode="decimal" type="number" placeholder="请输入交易数量">
<span slot="end">{{ symbol }}</span>
</ion-input>
<ion-input v-model="form.price" label="金额" class="count" inputmode="decimal" type="number" placeholder="请输入交易金额">
<span slot="end">USDT</span>
</ion-input>
<ion-range class="range" aria-label="Range with ticks" :pin="true" :ticks="true" :snaps="true" :min="0" :max="5" />
<ion-button expand="block" size="small" shape="round" :color="mode === 'buy' ? 'success' : 'danger'">
{{ mode === 'buy' ? '买入' : '卖出' }}
<!-- <ion-range class="range" aria-label="Range with ticks" :pin="true" :ticks="true" :snaps="true" :min="0" :max="5" /> -->
<ion-button expand="block" size="small" shape="round" :color="mode === TradeTypeEnum.BUY ? 'success' : 'danger'" @click="handleSubmit">
{{ mode === TradeTypeEnum.BUY ? '买入' : '卖出' }}
</ion-button>
</div>
<div class="col-span-2" />