feat: 添加交易相关组件,包括订单簿、交易表单和交易对头部信息,优化交易界面
This commit is contained in:
158
src/views/trade/components/order-book.vue
Normal file
158
src/views/trade/components/order-book.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface OrderBookItem {
|
||||||
|
price: string;
|
||||||
|
amount: string;
|
||||||
|
total: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderBookData {
|
||||||
|
bids: OrderBookItem[]; // 买单
|
||||||
|
asks: OrderBookItem[]; // 卖单
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 后续从API获取实时数据
|
||||||
|
// const { data } = await client.api.trading.orderbook.get({ query: { symbol: 'BTC_USDT', depth: 20 } })
|
||||||
|
const orderBook = ref<OrderBookData>({
|
||||||
|
asks: [
|
||||||
|
{ price: "43260.50", amount: "0.125", total: "5.407" },
|
||||||
|
{ price: "43258.00", amount: "0.342", total: "14.792" },
|
||||||
|
{ price: "43255.80", amount: "0.856", total: "37.027" },
|
||||||
|
{ price: "43253.20", amount: "1.234", total: "53.350" },
|
||||||
|
{ price: "43251.00", amount: "0.678", total: "29.324" },
|
||||||
|
{ price: "43249.50", amount: "2.145", total: "92.740" },
|
||||||
|
{ price: "43247.00", amount: "0.456", total: "19.720" },
|
||||||
|
{ price: "43245.80", amount: "1.567", total: "67.768" },
|
||||||
|
],
|
||||||
|
bids: [
|
||||||
|
{ price: "43250.50", amount: "0.234", total: "10.120" },
|
||||||
|
{ price: "43248.00", amount: "0.567", total: "24.522" },
|
||||||
|
{ price: "43245.50", amount: "1.234", total: "53.365" },
|
||||||
|
{ price: "43243.20", amount: "0.789", total: "34.118" },
|
||||||
|
{ price: "43240.00", amount: "2.456", total: "106.197" },
|
||||||
|
{ price: "43238.50", amount: "0.345", total: "14.917" },
|
||||||
|
{ price: "43235.00", amount: "1.678", total: "72.548" },
|
||||||
|
{ price: "43232.80", amount: "0.923", total: "39.903" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算买卖盘深度百分比(用于显示背景条)
|
||||||
|
function getDepthPercent(items: OrderBookItem[], index: number) {
|
||||||
|
const maxTotal = Math.max(...items.map(item => Number.parseFloat(item.total)));
|
||||||
|
const currentTotal = Number.parseFloat(items[index].total);
|
||||||
|
return (currentTotal / maxTotal) * 100;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="order-book">
|
||||||
|
<!-- 卖单 (asks) - 从下往上排列 -->
|
||||||
|
<div class="asks-section">
|
||||||
|
<div
|
||||||
|
v-for="(ask, index) in orderBook.asks.slice().reverse()"
|
||||||
|
:key="`ask-${index}`"
|
||||||
|
class="order-item ask-item"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="depth-bg ask-depth"
|
||||||
|
:style="{ width: `${getDepthPercent(orderBook.asks, orderBook.asks.length - 1 - index)}%` }"
|
||||||
|
/>
|
||||||
|
<div class="order-content">
|
||||||
|
<span class="price text-danger-500">{{ ask.price }}</span>
|
||||||
|
<span class="amount">{{ ask.amount }}</span>
|
||||||
|
<span class="total">{{ ask.total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中间分隔 - 显示最新成交价 -->
|
||||||
|
<div class="spread-section">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2">
|
||||||
|
<div class="text-lg font-semibold text-success-500">
|
||||||
|
43250.50
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-text-500">
|
||||||
|
≈ $43,250.50
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 买单 (bids) -->
|
||||||
|
<div class="bids-section">
|
||||||
|
<div
|
||||||
|
v-for="(bid, index) in orderBook.bids"
|
||||||
|
:key="`bid-${index}`"
|
||||||
|
class="order-item bid-item"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="depth-bg bid-depth"
|
||||||
|
:style="{ width: `${getDepthPercent(orderBook.bids, index)}%` }"
|
||||||
|
/>
|
||||||
|
<div class="order-content">
|
||||||
|
<span class="price text-success-500">{{ bid.price }}</span>
|
||||||
|
<span class="amount">{{ bid.amount }}</span>
|
||||||
|
<span class="total">{{ bid.total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.order-book {
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asks-section,
|
||||||
|
.bids-section {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-item {
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.depth-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-depth {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bid-depth {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
padding: 0 12px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount,
|
||||||
|
.total {
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spread-section {
|
||||||
|
border-top: 1px solid var(--ion-border-color);
|
||||||
|
border-bottom: 1px solid var(--ion-border-color);
|
||||||
|
background: var(--ion-background-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
333
src/views/trade/components/orders-panel.vue
Normal file
333
src/views/trade/components/orders-panel.vue
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Order {
|
||||||
|
id: string;
|
||||||
|
symbol: string;
|
||||||
|
side: "buy" | "sell";
|
||||||
|
type: "limit" | "market";
|
||||||
|
price: string;
|
||||||
|
amount: string;
|
||||||
|
filled: string;
|
||||||
|
total: string;
|
||||||
|
status: "pending" | "partial" | "filled" | "cancelled";
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = "current" | "history";
|
||||||
|
|
||||||
|
const activeTab = ref<TabType>("current");
|
||||||
|
|
||||||
|
// TODO: 后续从API获取当前委托订单
|
||||||
|
// const { data: currentOrders } = await client.api.trading.orders.get({ query: { status: 'open' } })
|
||||||
|
const currentOrders = ref<Order[]>([
|
||||||
|
{
|
||||||
|
id: "1001",
|
||||||
|
symbol: "BTC_USDT",
|
||||||
|
side: "buy",
|
||||||
|
type: "limit",
|
||||||
|
price: "43200.00",
|
||||||
|
amount: "0.5000",
|
||||||
|
filled: "0.2500",
|
||||||
|
total: "21600.00",
|
||||||
|
status: "partial",
|
||||||
|
time: "2024-12-27 10:30:25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "1002",
|
||||||
|
symbol: "BTC_USDT",
|
||||||
|
side: "sell",
|
||||||
|
type: "limit",
|
||||||
|
price: "43500.00",
|
||||||
|
amount: "0.3000",
|
||||||
|
filled: "0.0000",
|
||||||
|
total: "13050.00",
|
||||||
|
status: "pending",
|
||||||
|
time: "2024-12-27 09:15:10",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TODO: 后续从API获取历史订单
|
||||||
|
// const { data: historyOrders } = await client.api.trading.orders.get({ query: { status: 'closed' } })
|
||||||
|
const historyOrders = ref<Order[]>([
|
||||||
|
{
|
||||||
|
id: "1003",
|
||||||
|
symbol: "BTC_USDT",
|
||||||
|
side: "buy",
|
||||||
|
type: "limit",
|
||||||
|
price: "42800.00",
|
||||||
|
amount: "0.2000",
|
||||||
|
filled: "0.2000",
|
||||||
|
total: "8560.00",
|
||||||
|
status: "filled",
|
||||||
|
time: "2024-12-26 18:45:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "1004",
|
||||||
|
symbol: "BTC_USDT",
|
||||||
|
side: "sell",
|
||||||
|
type: "market",
|
||||||
|
price: "43100.00",
|
||||||
|
amount: "0.1500",
|
||||||
|
filled: "0.1500",
|
||||||
|
total: "6465.00",
|
||||||
|
status: "filled",
|
||||||
|
time: "2024-12-26 16:20:15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "1005",
|
||||||
|
symbol: "BTC_USDT",
|
||||||
|
side: "buy",
|
||||||
|
type: "limit",
|
||||||
|
price: "42500.00",
|
||||||
|
amount: "0.4000",
|
||||||
|
filled: "0.0000",
|
||||||
|
total: "17000.00",
|
||||||
|
status: "cancelled",
|
||||||
|
time: "2024-12-26 14:10:05",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const displayOrders = computed(() => {
|
||||||
|
return activeTab.value === "current" ? currentOrders.value : historyOrders.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getStatusText(status: Order["status"]) {
|
||||||
|
const statusMap = {
|
||||||
|
pending: "未成交",
|
||||||
|
partial: "部分成交",
|
||||||
|
filled: "已成交",
|
||||||
|
cancelled: "已取消",
|
||||||
|
};
|
||||||
|
return statusMap[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: Order["status"]) {
|
||||||
|
const colorMap = {
|
||||||
|
pending: "warning",
|
||||||
|
partial: "primary",
|
||||||
|
filled: "success",
|
||||||
|
cancelled: "medium",
|
||||||
|
};
|
||||||
|
return colorMap[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelOrder(orderId: string) {
|
||||||
|
// TODO: 后续调用取消订单API
|
||||||
|
// await client.api.trading.order[orderId].delete()
|
||||||
|
console.log("取消订单", orderId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="orders-panel">
|
||||||
|
<!-- Tab切换 -->
|
||||||
|
<div class="tabs-header">
|
||||||
|
<button
|
||||||
|
:class="{ active: activeTab === 'current' }"
|
||||||
|
@click="activeTab = 'current'"
|
||||||
|
>
|
||||||
|
当前委托 ({{ currentOrders.length }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: activeTab === 'history' }"
|
||||||
|
@click="activeTab = 'history'"
|
||||||
|
>
|
||||||
|
历史记录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 订单列表 -->
|
||||||
|
<div class="orders-list">
|
||||||
|
<div v-if="displayOrders.length === 0" class="empty-state">
|
||||||
|
<div class="text-text-500 text-sm">
|
||||||
|
{{ activeTab === 'current' ? '暂无当前委托' : '暂无历史记录' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="orders-content">
|
||||||
|
<div
|
||||||
|
v-for="order in displayOrders"
|
||||||
|
:key="order.id"
|
||||||
|
class="order-item"
|
||||||
|
>
|
||||||
|
<div class="order-header">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="side-badge"
|
||||||
|
:class="order.side === 'buy' ? 'buy' : 'sell'"
|
||||||
|
>
|
||||||
|
{{ order.side === 'buy' ? '买入' : '卖出' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium">{{ order.symbol }}</span>
|
||||||
|
<ion-badge :color="getStatusColor(order.status)" class="status-badge">
|
||||||
|
{{ getStatusText(order.status) }}
|
||||||
|
</ion-badge>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="activeTab === 'current' && order.status !== 'filled'"
|
||||||
|
class="cancel-btn"
|
||||||
|
@click="cancelOrder(order.id)"
|
||||||
|
>
|
||||||
|
撤单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="label">{{ order.type === 'limit' ? '价格' : '市价' }}</span>
|
||||||
|
<span class="value">{{ order.type === 'limit' ? order.price : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="label">数量</span>
|
||||||
|
<span class="value">{{ order.amount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="label">成交</span>
|
||||||
|
<span class="value">{{ order.filled }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="label">总额</span>
|
||||||
|
<span class="value">{{ order.total }} USDT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-footer">
|
||||||
|
<span class="time">{{ order.time }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.orders-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ion-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-header {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--ion-border-color);
|
||||||
|
background: var(--ion-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-header button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-header button.active {
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
border-bottom-color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-item {
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-badge.buy {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: var(--ion-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-badge.sell {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--ion-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--ion-color-danger);
|
||||||
|
color: var(--ion-color-danger);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:active {
|
||||||
|
background: var(--ion-color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row .label {
|
||||||
|
color: var(--ion-text-color-step-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row .value {
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-footer {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--ion-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-footer .time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ion-text-color-step-500);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
292
src/views/trade/components/trade-form.vue
Normal file
292
src/views/trade/components/trade-form.vue
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
<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>
|
||||||
90
src/views/trade/components/trading-pair-header.vue
Normal file
90
src/views/trade/components/trading-pair-header.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { chevronDownOutline } from "ionicons/icons";
|
||||||
|
|
||||||
|
interface TradingPairData {
|
||||||
|
symbol: string;
|
||||||
|
baseAsset: string;
|
||||||
|
quoteAsset: string;
|
||||||
|
lastPrice: string;
|
||||||
|
priceChange24h: string;
|
||||||
|
priceChangePercent24h: string;
|
||||||
|
high24h: string;
|
||||||
|
low24h: string;
|
||||||
|
volume24h: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 后续从API获取数据
|
||||||
|
// const { data } = await client.api.trading.pair.get({ query: { symbol: 'BTC_USDT' } })
|
||||||
|
const tradingPair = ref<TradingPairData>({
|
||||||
|
symbol: "BTC_USDT",
|
||||||
|
baseAsset: "BTC",
|
||||||
|
quoteAsset: "USDT",
|
||||||
|
lastPrice: "43250.50",
|
||||||
|
priceChange24h: "+1250.30",
|
||||||
|
priceChangePercent24h: "+2.98",
|
||||||
|
high24h: "43800.00",
|
||||||
|
low24h: "41500.00",
|
||||||
|
volume24h: "1,234.56",
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPositive = computed(() => !tradingPair.value.priceChangePercent24h.startsWith("-"));
|
||||||
|
|
||||||
|
function openPairSelector() {
|
||||||
|
// TODO: 打开交易对选择器
|
||||||
|
console.log("打开交易对选择器");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="trading-pair-header">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3">
|
||||||
|
<!-- 交易对信息 -->
|
||||||
|
<div class="flex items-center gap-2" @click="openPairSelector">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-base font-semibold">{{ tradingPair.baseAsset }}/{{ tradingPair.quoteAsset }}</span>
|
||||||
|
<ion-icon :icon="chevronDownOutline" class="text-sm text-text-400" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-text-500">
|
||||||
|
现货
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 价格信息 -->
|
||||||
|
<div class="flex-1 flex justify-end items-center gap-4">
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<div class="text-lg font-semibold" :class="isPositive ? 'text-success-500' : 'text-danger-500'">
|
||||||
|
{{ tradingPair.lastPrice }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs" :class="isPositive ? 'text-success-500' : 'text-danger-500'">
|
||||||
|
{{ tradingPair.priceChangePercent24h }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 24h数据 -->
|
||||||
|
<div class="grid grid-cols-3 gap-4 px-4 pb-3 text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-text-500">24h高</span>
|
||||||
|
<span class="font-medium mt-1">{{ tradingPair.high24h }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-text-500">24h低</span>
|
||||||
|
<span class="font-medium mt-1">{{ tradingPair.low24h }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-text-500">24h量({{ tradingPair.baseAsset }})</span>
|
||||||
|
<span class="font-medium mt-1">{{ tradingPair.volume24h }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trading-pair-header {
|
||||||
|
background: var(--ion-background-color);
|
||||||
|
border-bottom: 1px solid var(--ion-border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,34 +1,127 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { t } = useI18n();
|
import OrderBook from "./components/order-book.vue";
|
||||||
|
import OrdersPanel from "./components/orders-panel.vue";
|
||||||
|
import TradeForm from "./components/trade-form.vue";
|
||||||
|
import TradingPairHeader from "./components/trading-pair-header.vue";
|
||||||
|
|
||||||
const tradingViewContainer = useTemplateRef<HTMLElement>("tradingViewContainer");
|
const tradingViewContainer = useTemplateRef<HTMLElement>("tradingViewContainer");
|
||||||
|
|
||||||
|
// K线图配置
|
||||||
useTradingView(tradingViewContainer, {
|
useTradingView(tradingViewContainer, {
|
||||||
data: [
|
data: [
|
||||||
// 随机k线图数据
|
// TODO: 后续从API获取K线数据
|
||||||
{ time: "2023-10-01", open: 100, high: 110, low: 90, close: 105 },
|
// const { data } = await client.api.trading.kline.get({ query: { symbol: 'BTC_USDT', interval: '1h' } })
|
||||||
{ time: "2023-10-02", open: 105, high: 115, low: 95, close: 100 },
|
{ time: "2023-10-01", open: 42000, high: 43100, low: 41800, close: 42500 },
|
||||||
{ time: "2023-10-03", open: 100, high: 120, low: 80, close: 110 },
|
{ time: "2023-10-02", open: 42500, high: 43500, low: 42200, close: 43000 },
|
||||||
{ time: "2023-10-04", open: 110, high: 130, low: 100, close: 120 },
|
{ time: "2023-10-03", open: 43000, high: 44200, low: 42800, close: 43800 },
|
||||||
{ time: "2023-10-05", open: 120, high: 140, low: 110, close: 130 },
|
{ time: "2023-10-04", open: 43800, high: 44500, low: 43500, close: 44200 },
|
||||||
{ time: "2023-10-06", open: 130, high: 150, low: 120, close: 140 },
|
{ time: "2023-10-05", open: 44200, high: 44800, low: 43900, close: 44500 },
|
||||||
{ time: "2023-10-07", open: 140, high: 160, low: 130, close: 150 },
|
{ time: "2023-10-06", open: 44500, high: 45000, low: 44200, close: 44800 },
|
||||||
|
{ time: "2023-10-07", open: 44800, high: 45500, low: 44500, close: 43250 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 买卖切换
|
||||||
|
const tradeAction = ref<"buy" | "sell">("buy");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ion-page>
|
<ion-page>
|
||||||
<ion-header class="ion-no-border">
|
<ion-header class="ion-no-border">
|
||||||
<ion-toolbar class="ui-toolbar">
|
<ion-toolbar class="ui-toolbar">
|
||||||
<ion-title>{{ t('tabs.trade') }}</ion-title>
|
<ion-title>交易</ion-title>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content :fullscreen="true">
|
<ion-content :fullscreen="true">
|
||||||
<div ref="tradingViewContainer" />
|
<!-- 交易对头部信息 -->
|
||||||
|
<TradingPairHeader />
|
||||||
|
|
||||||
|
<!-- K线图表 -->
|
||||||
|
<div ref="tradingViewContainer" class="chart-container" />
|
||||||
|
|
||||||
|
<!-- 订单簿 -->
|
||||||
|
<div class="order-book-section">
|
||||||
|
<OrderBook />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 买卖切换 -->
|
||||||
|
<div class="trade-action-tabs">
|
||||||
|
<button
|
||||||
|
:class="{ active: tradeAction === 'buy' }"
|
||||||
|
class="buy-tab"
|
||||||
|
@click="tradeAction = 'buy'"
|
||||||
|
>
|
||||||
|
买入
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: tradeAction === 'sell' }"
|
||||||
|
class="sell-tab"
|
||||||
|
@click="tradeAction = 'sell'"
|
||||||
|
>
|
||||||
|
卖出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 交易表单 -->
|
||||||
|
<TradeForm :action="tradeAction" />
|
||||||
|
|
||||||
|
<!-- 当前委托和历史记录 -->
|
||||||
|
<div class="orders-section">
|
||||||
|
<OrdersPanel />
|
||||||
|
</div>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
</ion-page>
|
</ion-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.chart-container {
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-book-section {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-action-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-action-tabs button {
|
||||||
|
padding: 12px 0;
|
||||||
|
border: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-action-tabs .buy-tab {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-action-tabs .sell-tab {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-action-tabs button.active.buy-tab {
|
||||||
|
background: var(--ion-color-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-action-tabs button.active.sell-tab {
|
||||||
|
background: var(--ion-color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function createWithdrawSchema(t: (key: string, params?: any) => string, m
|
|||||||
.string()
|
.string()
|
||||||
.required(t("withdraw.validation.amountRequired"))
|
.required(t("withdraw.validation.amountRequired"))
|
||||||
.test("is-number", t("withdraw.validation.amountInvalid"), (value) => {
|
.test("is-number", t("withdraw.validation.amountInvalid"), (value) => {
|
||||||
return /^\d+(\.\d+)?$/.test(value || "");
|
return /^\d+(?:\.\d+)?$/.test(value || "");
|
||||||
})
|
})
|
||||||
.test("max-amount", t("withdraw.validation.amountExceedsBalance"), (value) => {
|
.test("max-amount", t("withdraw.validation.amountExceedsBalance"), (value) => {
|
||||||
if (!value || maxAmount === "0")
|
if (!value || maxAmount === "0")
|
||||||
|
|||||||
Reference in New Issue
Block a user